diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 6971383b851e5..62e7318ead098 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -162,6 +162,22 @@ it.each([ expect(textWithMark(editor, "code")).toEqual(code); }); +it.each([true, false])("styles bare URLs as links (rich text: %s)", async (richTextEnabled) => { + await using screen = await mountEditor( + "see https://github.com/pingdotgg/t3code/pull/1. and `https://example.com`", + richTextEnabled, + ); + const links = Array.from( + screen.container.querySelectorAll(".composer-link"), + (link) => link.textContent, + ); + expect(links).toEqual(richTextEnabled ? ["https://github.com/pingdotgg/t3code/pull/1"] : []); + // github.com gets the brand icon rather than a fetched favicon. + expect(screen.container.querySelectorAll(".ProseMirror-widget svg")).toHaveLength( + richTextEnabled ? 1 : 0, + ); +}); + it.each([ { mark: "bold", key: "b", marker: "**" }, { mark: "italic", key: "i", marker: "*" }, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5895b63cbb9a0..534923c94a43f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -133,6 +133,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; +import { ComposerLinkExtension } from "./composerLinkDecorations"; import { importPastedComposerText, readPastedPlainText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -839,6 +840,7 @@ function createComposerExtensions(richText: boolean) { ComposerBoldExtension, ComposerStrikeExtension, ComposerCodeExtension, + ComposerLinkExtension, ComposerCodeBlockExtension.extend({ addNodeView() { return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { diff --git a/apps/web/src/components/chat/MarkdownExternalLink.tsx b/apps/web/src/components/chat/MarkdownExternalLink.tsx index e1d8f0a06bb57..5bfe4cf978047 100644 --- a/apps/web/src/components/chat/MarkdownExternalLink.tsx +++ b/apps/web/src/components/chat/MarkdownExternalLink.tsx @@ -1,52 +1,6 @@ -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; -import { GlobeIcon } from "lucide-react"; -import { Children, memo, useState, type ReactNode } from "react"; +import { Children, 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(); - -function sourceControlHostIcon(host: string): ReactNode { - const definition = sourceControlClients.findByPublicDomain(host); - if (definition === undefined) return null; - const Icon = sourceControlIcon(definition); - return ; -} - -const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { - const [failedHost, setFailedHost] = useState(null); - const brandIcon = sourceControlHostIcon(host); - const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); - return ( - - {brandIcon ? ( - brandIcon - ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( - - ) : ( - { - failedFaviconHosts.add(host); - setFailedHost(host); - }} - /> - )} - - ); -}); +import { MarkdownLinkFavicon } from "./MarkdownLinkFavicon"; function leadingExternalLinkTextLength(text: string): number { const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; diff --git a/apps/web/src/components/chat/MarkdownLinkFavicon.tsx b/apps/web/src/components/chat/MarkdownLinkFavicon.tsx new file mode 100644 index 0000000000000..a335f11dfabb2 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownLinkFavicon.tsx @@ -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(); + +function sourceControlHostIcon(host: string): ReactNode { + const definition = sourceControlClients.findByPublicDomain(host); + if (definition === undefined) return null; + const Icon = sourceControlIcon(definition); + return ; +} + +export const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { + const [failedHost, setFailedHost] = useState(null); + const brandIcon = sourceControlHostIcon(host); + const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); + return ( + + {brandIcon ? ( + brandIcon + ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( + + ) : ( + { + failedFaviconHosts.add(host); + setFailedHost(host); + }} + /> + )} + + ); +}); diff --git a/apps/web/src/components/composerLinkDecorations.ts b/apps/web/src/components/composerLinkDecorations.ts new file mode 100644 index 0000000000000..fc3c980a1c90b --- /dev/null +++ b/apps/web/src/components/composerLinkDecorations.ts @@ -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 = /(? { + 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(); + 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; + }, + }; + }, +}); diff --git a/apps/web/src/composer-links.test.ts b/apps/web/src/composer-links.test.ts new file mode 100644 index 0000000000000..623f4d3b60f29 --- /dev/null +++ b/apps/web/src/composer-links.test.ts @@ -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"]); + }); +}); diff --git a/apps/web/src/composer-links.ts b/apps/web/src/composer-links.ts new file mode 100644 index 0000000000000..f6c1cd92634c9 --- /dev/null +++ b/apps/web/src/composer-links.ts @@ -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 = /(? 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; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 654ef108e8e75..438a14d37e179 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2465,6 +2465,11 @@ code { padding: 0.05em 0.25em; } +.composer-tiptap .composer-link, +.composer-tiptap .composer-link-icon { + color: var(--info-foreground); +} + /* Markdown delimiters shown, as editable text, around styled text with the caret in it. */ .composer-rich-marker { color: var(--muted-foreground);