Repository navigation
feat(web): links in the rich text composer look like links #22437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+247
−48
Closed
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
18d2779
feat(web): links in the rich text composer look like links
Yuruzuu ad0d038
fix(web): composer links drop entity-like suffixes as GFM does
Yuruzuu 30c8703
Merge remote-tracking branch 'upstream/main' into feat/composer-rich-…
Yuruzuu 9cde998
Merge remote-tracking branch 'upstream/main' into t3code/pr-22437/fea…
Yuruzuu 3710ff5
Merge remote-tracking branch 'upstream/main' into t3code/pr-22437/fea…
Yuruzuu a41cb52
chore(web): merge main into composer link styling
maria-rcks f6ca5ab
chore: merge main
maria-rcks a6c46a0
chore: merge main into composer links
maria-rcks b4d7934
fix(web): composer link icons use the link color
Yuruzuu 4413636
Merge remote-tracking branch 'github/main' into feat/composer-rich-te…
maria-rcks ccdbe65
fix(web): composer links skip inline code shown as Markdown source
maria-rcks 110c7af
Merge remote-tracking branch 'upstream/main' into HEAD
Yuruzuu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; | ||
| import { GlobeIcon } from "lucide-react"; | ||
| import { memo, useState, type ReactNode } from "react"; | ||
|
|
||
| import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
|
|
||
| import { cn } from "~/lib/utils"; | ||
| import { sourceControlIcon } from "../../sourceControlPresentation"; | ||
|
|
||
| const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; | ||
|
|
||
| const failedFaviconHosts = new Set<string>(); | ||
|
|
||
| function sourceControlHostIcon(host: string): ReactNode { | ||
| const definition = sourceControlClients.findByPublicDomain(host); | ||
| if (definition === undefined) return null; | ||
| const Icon = sourceControlIcon(definition); | ||
| return <Icon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />; | ||
| } | ||
|
|
||
| export const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { | ||
| const [failedHost, setFailedHost] = useState<string | null>(null); | ||
| const brandIcon = sourceControlHostIcon(host); | ||
| const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); | ||
| return ( | ||
| <span | ||
| className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]" | ||
| aria-hidden | ||
| > | ||
| {brandIcon ? ( | ||
| brandIcon | ||
| ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( | ||
| <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} /> | ||
| ) : ( | ||
| <img | ||
| src={faviconUrl} | ||
| alt="" | ||
| loading="lazy" | ||
| draggable={false} | ||
| className={cn(MARKDOWN_LINK_FAVICON_CLASS_NAME, "rounded-sm")} | ||
| onError={() => { | ||
| failedFaviconHosts.add(host); | ||
| setFailedHost(host); | ||
| }} | ||
| /> | ||
| )} | ||
| </span> | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { Decoration, Extension } from "@tiptap/core"; | ||
| import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; | ||
| import { ReactWidgetRenderer } from "@tiptap/react"; | ||
|
|
||
| import { type ComposerLink, findComposerLinks } from "~/composer-links"; | ||
| import { MARKDOWN_SOURCE_MARK } from "~/composer-rich-text"; | ||
| import { MarkdownLinkFavicon } from "./chat/MarkdownLinkFavicon"; | ||
|
|
||
| // Revealed Markdown source shows inline code as backtick text without the code mark. | ||
| const CODE_SPAN = /(?<!`)(`+)(?!`)[^]*?(?<!`)\1(?!`)/gu; | ||
|
|
||
| function collectComposerLinks(doc: ProseMirrorNode): ComposerLink[] { | ||
| const links: ComposerLink[] = []; | ||
| doc.descendants((node, pos) => { | ||
| if (!node.isTextblock) return true; | ||
| if (node.type.spec.code) return false; | ||
| // A URL can span several text nodes (part of it bold, say), but not a | ||
| // chip, a hard break, or inline code. | ||
| let run = ""; | ||
| let runStart = 0; | ||
| const flush = () => { | ||
| for (const link of findComposerLinks(run)) { | ||
| links.push({ from: runStart + link.from, to: runStart + link.to, host: link.host }); | ||
| } | ||
| run = ""; | ||
| }; | ||
| node.forEach((child, offset) => { | ||
| if (child.isText && !child.marks.some((mark) => mark.type.spec.code)) { | ||
| if (run === "") runStart = pos + 1 + offset; | ||
| run += child.marks.some((mark) => mark.type.name === MARKDOWN_SOURCE_MARK) | ||
| ? child.text!.replace(CODE_SPAN, (span) => " ".repeat(span.length)) | ||
| : child.text; | ||
| } else { | ||
| flush(); | ||
| } | ||
| }); | ||
| flush(); | ||
| return false; | ||
| }); | ||
| return links; | ||
| } | ||
|
|
||
| /** Colors bare URLs like the sent message's links and leads each with its site icon. */ | ||
| export const ComposerLinkExtension = Extension.create({ | ||
| name: "composerLinks", | ||
| addDecorations() { | ||
| const editor = this.editor; | ||
| return { | ||
| create: ({ state }) => { | ||
| const decorations: Decoration[] = []; | ||
| const occurrencesByHost = new Map<string, number>(); | ||
| for (const link of collectComposerLinks(state.doc)) { | ||
| // Widget keys must be unique and should not follow positions, or | ||
| // typing before a link would remount its icon on every keystroke. | ||
| const occurrence = occurrencesByHost.get(link.host) ?? 0; | ||
| occurrencesByHost.set(link.host, occurrence + 1); | ||
| decorations.push( | ||
| ReactWidgetRenderer(MarkdownLinkFavicon, { | ||
| editor, | ||
| pos: link.from, | ||
| key: `composer-link-favicon:${link.host}:${occurrence}`, | ||
| props: { host: link.host }, | ||
| className: "composer-link-icon", | ||
| side: -1, | ||
| }), | ||
| Decoration.Inline(link.from, link.to, { class: "composer-link" }), | ||
| ); | ||
| } | ||
| return decorations; | ||
| }, | ||
| }; | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { findComposerLinks } from "./composer-links"; | ||
|
|
||
| function linkTexts(text: string): string[] { | ||
| return findComposerLinks(text).map(({ from, to }) => text.slice(from, to)); | ||
| } | ||
|
|
||
| describe("findComposerLinks", () => { | ||
| it("finds http(s) URLs with their host", () => { | ||
| const url = "https://github.com/pingdotgg/t3code/pull/22426#pullrequestreview-1"; | ||
| expect(findComposerLinks(`see ${url} now`)).toEqual([ | ||
| { from: 4, to: 4 + url.length, host: "github.com" }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("drops trailing punctuation and unbalanced closing parens", () => { | ||
| expect( | ||
| linkTexts("(https://example.com/a), https://example.com/b. [x](https://example.com/c)"), | ||
| ).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]); | ||
| expect(linkTexts("https://en.wikipedia.org/wiki/Foo_(bar)!")).toEqual([ | ||
| "https://en.wikipedia.org/wiki/Foo_(bar)", | ||
| ]); | ||
| }); | ||
|
|
||
| it("drops entity-like suffixes whole, as GFM does", () => { | ||
| expect( | ||
| linkTexts( | ||
| "https://example.com?q=x&hl; https://example.com/a.&hl;> https://example.com/b&1; https://example.com/c&;", | ||
| ), | ||
| ).toEqual([ | ||
| "https://example.com?q=x", | ||
| "https://example.com/a", | ||
| "https://example.com/b&1", | ||
| "https://example.com/c&", | ||
| ]); | ||
| }); | ||
|
|
||
| it("matches GFM's boundaries and keeps quotes inside the path", () => { | ||
| expect( | ||
| linkTexts('[https://github.com] 1https://example.com/a\'b "https://example.com/c"'), | ||
| ).toEqual(["https://github.com", "https://example.com/a'b", "https://example.com/c"]); | ||
| }); | ||
|
|
||
| it("ignores URLs glued to a letter or without a host", () => { | ||
| expect(linkTexts("xhttps://example.com https:// ftp://example.com")).toEqual([]); | ||
| }); | ||
|
|
||
| it("trims a long run of closing parens in linear time", () => { | ||
| const parens = ")".repeat(200_000); | ||
| expect(linkTexts(`https://example.com${parens}`)).toEqual(["https://example.com"]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { resolveExternalWebLinkHost } from "./components/chat/externalLinkContextMenu"; | ||
|
|
||
| export interface ComposerLink { | ||
| readonly from: number; | ||
| readonly to: number; | ||
| readonly host: string; | ||
| } | ||
|
|
||
| // Mirrors GFM's autolink literals in the sent message: an http(s) URL not | ||
| // glued to a preceding letter, minus trailing punctuation and unbalanced `)`. | ||
| const URL_PATTERN = /(?<![A-Za-z])https?:\/\/[^\s<]+/giu; | ||
| const TRAILING_PUNCTUATION = new Set(["!", '"', "'", "*", ",", ".", ":", ";", "?", "]", "_", "~"]); | ||
|
|
||
| function trimLinkEnd(url: string): string { | ||
| let unbalancedCloses = 0; | ||
| for (const character of url) { | ||
| if (character === "(") unbalancedCloses -= 1; | ||
| else if (character === ")") unbalancedCloses += 1; | ||
| } | ||
| let end = url.length; | ||
| while (end > 0) { | ||
| const character = url[end - 1]!; | ||
| if (character === ";") { | ||
| // GFM drops an entity-like `&name;` suffix whole, not just its `;`. | ||
| let nameStart = end - 1; | ||
| while (nameStart > 0 && /[A-Za-z]/.test(url[nameStart - 1]!)) nameStart -= 1; | ||
| if (nameStart < end - 1 && url[nameStart - 1] === "&") { | ||
| end = nameStart - 1; | ||
| continue; | ||
| } | ||
| } | ||
| if (character === ")" && unbalancedCloses > 0) unbalancedCloses -= 1; | ||
| else if (!TRAILING_PUNCTUATION.has(character)) break; | ||
| end -= 1; | ||
| } | ||
| return url.slice(0, end); | ||
| } | ||
|
|
||
| export function findComposerLinks(text: string): ComposerLink[] { | ||
| const links: ComposerLink[] = []; | ||
| for (const match of text.matchAll(URL_PATTERN)) { | ||
| const url = trimLinkEnd(match[0]); | ||
| const host = resolveExternalWebLinkHost(url); | ||
| if (host) links.push({ from: match.index, to: match.index + url.length, host }); | ||
| } | ||
| return links; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.