Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
type ComposerPromptEditorProps,
} from "./ComposerPromptEditorTiptap";

import { importPastedComposerText, markPlainTextPaste } from "./composerInlineTokenPaste";

vi.mock(import("../state/server"), async (original) => {
const { Atom } = await import("effect/reactivity");
const { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } =
Expand Down Expand Up @@ -390,6 +392,169 @@ it("lets composition replace selected text without surrounding it", async () =>
expect(serializeEditorDoc(editor.state.doc).value).toBe('"');
});

it.each([
{
name: "a titled browser link",
plain: "Example report",
html: '<a href="https://example.com/report">Example report</a>',
expected: "https://example.com/report",
richText: true,
},
{
name: "a titled link with surrounding whitespace",
plain: " Example report ",
html: '<a href="https://example.com/report">Example report</a>',
expected: " https://example.com/report ",
richText: true,
},
{
name: "a titled link with Paste as Text",
plain: "Example report",
html: '<a href="https://example.com/report">Example report</a>',
expected: "Example report",
richText: true,
asText: true,
},
{
name: "a titled link in plain mode",
plain: "Example report",
html: '<a href="https://example.com/report">Example report</a>',
expected: "https://example.com/report",
richText: false,
},
{
name: "a browser HTML fragment with nested label formatting",
plain: "Example report",
html: '<html><body><!--StartFragment--><p><a href="https://example.com/report?q=one&amp;page=2"><strong>Example report</strong></a></p><!--EndFragment--></body></html>',
expected: "https://example.com/report?q=one&page=2",
richText: true,
},
{
name: "a destination containing Markdown and composer syntax",
plain: "Example report",
html: '<a href="https://example.com/_report_/$HOME?q=**value**">Example report</a>',
expected: "https://example.com/_report_/$HOME?q=**value**",
richText: true,
},
{
name: "an explicit plain-text URL",
plain: "https://example.com/original",
html: '<a href="https://example.com/other">https://example.com/original</a>',
expected: "https://example.com/original",
richText: true,
},
{
name: "an explicit non-HTTP URL",
plain: "ftp://example.com/file",
html: '<a href="https://example.com/other">ftp://example.com/file</a>',
expected: "ftp://example.com/file",
richText: true,
},
{
name: "an explicit mailto URL",
plain: "mailto:user@example.com",
html: '<a href="https://example.com/other">mailto:user@example.com</a>',
expected: "mailto:user@example.com",
richText: true,
},
{
name: "a title containing a colon",
plain: "Review: update",
html: '<a href="https://example.com/review">Review: update</a>',
expected: "https://example.com/review",
richText: true,
},
{
name: "text surrounding a link",
plain: "Read Example report now",
html: '<p>Read <a href="https://example.com/report">Example report</a> now</p>',
expected: "Read Example report now",
richText: true,
},
{
name: "multiple links",
plain: "First Second",
html: '<a href="https://example.com/first">First</a> <a href="https://example.com/second">Second</a>',
expected: "First Second",
richText: true,
},
{
name: "a non-web destination",
plain: "Example report",
html: '<a href="javascript:alert(1)">Example report</a>',
expected: "Example report",
richText: true,
},
{
name: "an unrelated HTML label",
plain: "Original clipboard text",
html: '<a href="https://example.com/report">Example report</a>',
expected: "Original clipboard text",
richText: true,
},
{
name: "T3 context clipboard HTML",
plain: "Example report",
html: '<div data-t3-context-fragment="context"><a href="https://example.com/report">Example report</a></div>',
expected: "Example report",
richText: true,
},
{
name: "an invalid web destination",
plain: "Example report",
html: '<a href="https://">Example report</a>',
expected: "Example report",
richText: true,
},
])("preserves the destination or original text when pasting $name", async (fixture) => {
await using screen = await mountEditor("", fixture.richText);
const { editor } = screen;
await act(async () => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", fixture.plain);
clipboardData.setData("text/html", fixture.html);
if (fixture.asText) markPlainTextPaste(clipboardData);
expect(importPastedComposerText(clipboardData)).toBe(fixture.expected);
editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true }));
});
expect(serializeEditorDoc(editor.state.doc).value).toBe(fixture.expected);
expect(editor.state.doc.textContent).toBe(fixture.expected);
});

it.each([
{ value: "", disabled: false },
{ value: "```text\nexisting\n```", disabled: false },
{ value: "", disabled: true },
])("preserves pasted link text when redirected to the end of %j", async ({ value, disabled }) => {
const editorRef = createRef<ComposerPromptEditorHandle>();
await using screen = await render(
<ComposerPromptEditor
editorRef={editorRef}
value={value}
cursor={0}
richTextEnabled
contextRecords={new Map()}
skills={[]}
disabled={disabled}
placeholder=""
onChange={() => {}}
onPaste={() => {}}
/>,
);
const url = "https://example.com/_report_/$HOME?q=**value**";
await act(async () => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", "Example report");
clipboardData.setData("text/html", `<a href="${url}">Example report</a>`);
expect(editorRef.current?.pasteAtEnd(clipboardData)).toBe(!disabled);
});
const editor = editorIn(screen.container);
expect(editor.state.doc.textContent).toBe(disabled ? "" : value ? "existingExample report" : url);
expect(serializeEditorDoc(editor.state.doc).value).toBe(
disabled ? "" : value ? "```text\nexistingExample report\n```" : url,
);
});

it.each([
{ at: "inside bold", selection: 5, composed: "**bo漢ld** after", bold: ["bo漢ld"] },
{
Expand Down
34 changes: 27 additions & 7 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
import { importPastedComposerText, readPastedPlainText } from "./composerInlineTokenPaste";
import {
importPastedComposerText,
readPastedComposerLink,
readPastedPlainText,
} from "./composerInlineTokenPaste";
import { didComposerSelectionChangeVisibly } from "./composerSelection";
import {
captureComposerAttachmentInsertion,
Expand All @@ -148,6 +152,7 @@ export interface ComposerPromptEditorHandle {
focus: () => void;
focusAt: (cursor: number) => void;
focusAtEnd: () => void;
pasteAtEnd: (clipboardData: DataTransfer) => boolean;
readSelectionRange: () => { start: number; end: number };
captureAttachmentInsertion: () => ComposerAttachmentInsertion | undefined;
requestCitationComment: (request: ComposerCitationCommentRequest) => void;
Expand Down Expand Up @@ -1430,11 +1435,14 @@ function ComposerPromptEditorTiptapInner(
return true;
}
const importFragment = readCallbacks().importContextFragment;
const pastedLink = literalText ? null : readPastedComposerLink(clipboardData);
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
let text =
!literalText && importFragment
pastedLink ??
(!literalText && importFragment
? importPastedComposerText(clipboardData, importFragment)
: pastedText;
const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`);
: pastedText);
const tokens =
literalText || pastedLink ? [] : collectComposerPromptInlineTokens(`${text}\n`);
const isChip = (token: (typeof tokens)[number] | undefined) =>
token?.type === "mention" ||
(token?.type === "skill" && skillLabelFor(token.value) !== null) ||
Expand All @@ -1450,9 +1458,11 @@ function ComposerPromptEditorTiptapInner(
}
const { $from } = view.state.selection;
const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC");
const literalLength = literalText
? 0
: pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.());
const literalLength = pastedLink
? text.length
: literalText
? 0
: pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.());
const editorInstance = (view.dom as TiptapEditorHTMLElement).editor;
if (editorInstance) {
const $paste = view.state.selection.$from;
Expand Down Expand Up @@ -1748,6 +1758,16 @@ function ComposerPromptEditorTiptapInner(
),
);
},
pasteAtEnd: (clipboardData) => {
if (!editor?.isEditable) return false;
editor.commands.setTextSelection(TextSelection.atEnd(editor.state.doc).from);
const pasted = editor.view.pasteText(
clipboardData.getData("text/plain"),
new ClipboardEvent("paste", { clipboardData }),
);
editor.commands.focus();
return pasted;
},
readSelectionRange: () => {
readSnapshot();
return selectionRangeRef.current;
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
import { isLocalEnvironmentDisabled } from "../../localEnvironment";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useRightPanelStore } from "~/rightPanelStore";
import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";
import {
importPastedComposerText,
markPlainTextPaste,
readPastedComposerContext,
readPastedComposerLink,
} from "../composerInlineTokenPaste";
import {
questionAttachmentDraftId,
trackOpenQuestionAttachmentDraft,
Expand Down Expand Up @@ -2569,6 +2574,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return false;
}
if (options?.clipboardData) {
if (position === "end" && readPastedComposerLink(options.clipboardData)) {
return composerEditorRef.current?.pasteAtEnd(options.clipboardData) ?? false;
}
text = importPastedComposerText(options.clipboardData, importContextFragment);
}
const prompt = promptRef.current;
Expand Down Expand Up @@ -2743,6 +2751,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const { files, folders } = splitTransferredFiles(event.clipboardData);
const plainText = event.clipboardData.getData("text/plain");
const bypassAutoAttachment = consumePasteAsTextShortcut(pasteAsTextShortcutUntilRef);
if (bypassAutoAttachment) markPlainTextPaste(event.clipboardData);
if (
folders.length > 0 ||
(activeThreadId &&
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/usePasteToFocusComposer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { pasteTextToFocusComposer, shouldRedirectInputToComposer } from "./composerFocusRedirect";
import { readPastedComposerContext } from "../composerInlineTokenPaste";
import { markPlainTextPaste, readPastedComposerContext } from "../composerInlineTokenPaste";
import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
import { useEffect, useRef } from "react";
import { isMacPlatform } from "../../lib/utils";
Expand Down Expand Up @@ -37,6 +37,7 @@ export function usePasteToFocusComposer(
if (text === null || clipboardData === null) return;
const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current;
pasteAsTextShortcutUntilRef.current = 0;
if (bypassAutoAttachment) markPlainTextPaste(clipboardData);
if (
((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 &&
composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) ||
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/composerInlineTokenPaste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@ import { readThreadShell } from "~/state/entities";

const threadTitle = (ref: ScopedThreadRef) => readThreadShell(ref)?.title;

const plainTextPastes = new WeakSet<object>();

/** Paste as Text keeps the clipboard's plain text, so it skips titled link recovery. */
export function markPlainTextPaste(clipboardData: object): void {
plainTextPastes.add(clipboardData);
}

export function readPastedComposerLink(
clipboardData: Pick<DataTransfer, "getData">,
): string | null {
if (plainTextPastes.has(clipboardData)) return null;
const pastedText = readPastedPlainText(clipboardData);
const text = pastedText.trim();
if (!text || /^[a-z][a-z\d+.-]*:\S+$/i.test(text)) return null;
if (clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) return null;
const html = clipboardData.getData("text/html");
if (!html || !/<a[\s>]/i.test(html)) return null;
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
const document = new DOMParser().parseFromString(html, "text/html");
if (document.querySelector("[data-t3-context-fragment]")) return null;
const links = document.querySelectorAll("a[href]");
if (links.length !== 1) return null;
const link = links[0]!;
const label = link.textContent?.trim();
if (label !== text || document.body.textContent?.trim() !== text) return null;
const href = link.getAttribute("href")?.trim();
if (!href || !/^https?:\/\/\S+$/i.test(href) || !URL.canParse(href)) return null;
return pastedText.replace(text, () => href);
}

// Matches ProseMirror's default clipboard parser, which a custom handlePaste bypasses.
export function readPastedPlainText(clipboardData: Pick<DataTransfer, "getData">): string {
return clipboardData.getData("text/plain").replace(/\r\n?/g, "\n");
Expand Down Expand Up @@ -70,6 +99,8 @@ export function importPastedComposerText(
fragment: ComposerContextClipboardFragment,
) => ReadonlyMap<string, string>,
): string {
const pastedLink = readPastedComposerLink(clipboardData);
if (pastedLink) return pastedLink;
const pastedText = readPastedPlainText(clipboardData);
const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null;
const rewrittenIds =
Expand Down
Loading