diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 925319680d40d..5bbe94d4a909d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -321,6 +321,64 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy expect(serializeEditorDoc(editor.state.doc).value).toBe(value); }); +it("pastes arithmetic stars verbatim and still styles pasted markdown", async () => { + await using screen = await mountEditor("", true, []); + const { editor } = screen; + const paste = async (text: string) => { + await act(async () => { + editor.commands.selectAll(); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + editor.view.dom.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, cancelable: true }), + ); + }); + }; + const code = "signal = (7 * 2**3 * 5)**4"; + await paste(code); + expect(serializeEditorDoc(editor.state.doc).value).toBe(code); + await paste("a **bold** and *soft* word"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("a **bold** and *soft* word"); + expect(editor.state.doc.textContent).toBe("a bold and soft word"); +}); + +it("parses markdown dropped from outside, keeps dropped arithmetic verbatim, and focuses", async () => { + await using screen = await mountEditor("", true, []); + const { editor } = screen; + const drop = async (text: string, html?: string) => { + await act(async () => { + editor.commands.clearContent(); + editor.view.dom.blur(); + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", text); + if (html) dataTransfer.setData("text/html", html); + const { left, top } = editor.view.coordsAtPos(1); + editor.view.dom.dispatchEvent( + new DragEvent("drop", { + dataTransfer, + clientX: left, + clientY: top + 1, + bubbles: true, + cancelable: true, + }), + ); + }); + }; + await drop("a **bold** and *soft* word"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("a **bold** and *soft* word"); + expect(editor.state.doc.textContent).toBe("a bold and soft word"); + expect(editor.view.hasFocus()).toBe(true); + await drop("bold", "

bold

"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("**bold**"); + const code = "signal = (7 * 2**3 * 5)**4"; + await drop(code); + expect(serializeEditorDoc(editor.state.doc).value).toBe(code); + // A drag that started inside the editor is ProseMirror's move, not an external text drop. + editor.view.dragging = { slice: editor.state.doc.slice(0, 0), move: false }; + await drop("a **bold** word"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(""); +}); + it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; await using screen = await mountEditor(value); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1986cc888efba..fa409fdf6ab70 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1098,6 +1098,101 @@ function ComposerPromptEditorTiptapInner( "aria-placeholder": placeholder, }; + const insertClipboardText = (view: EditorView, clipboardData: DataTransfer): boolean => { + const pastedText = clipboardData.getData("text/plain"); + if (!pastedText) return false; + // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, from, to), + "paste", + ).scrollIntoView(), + ); + return true; + } + // Shown Markdown source is parsed when the caret leaves, so paste one line into it as text. + const sourceType = view.state.schema.marks[MARKDOWN_SOURCE_MARK]; + const { from: pasteFrom, to: pasteTo, $from: $pasteFrom } = view.state.selection; + if ( + sourceType?.isInSet($pasteFrom.marks()) && + !pastedText.includes("\n") && + collectComposerPromptInlineTokens(`${pastedText}\n`).length === 0 + ) { + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, pasteFrom, pasteTo), + "paste", + ).scrollIntoView(), + ); + return true; + } + const importFragment = readCallbacks().importContextFragment; + let text = + !literalText && importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; + const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); + const isChip = (token: (typeof tokens)[number] | undefined) => + token?.type === "mention" || + (token?.type === "skill" && skillLabelFor(token.value) !== null) || + (token?.type === "model" && isKnownModel(token.instanceId, token.model)); + const lastToken = tokens.at(-1); + if (isChip(lastToken) && lastToken?.end === text.length) { + text += " "; + } + if (isChip(tokens[0]) && tokens[0]?.start === 0) { + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); + if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; + } + 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 editorInstance = (view.dom as TiptapEditorHTMLElement).editor; + if (editorInstance) { + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, + (content) => { + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + // Pasted chips and lines would split shown Markdown, so parse it first. + .command(collapseShownMarkdownSource) + .insertContent(content) + .command(({ tr }) => { + // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line + // if needed. + const { selection } = tr; + if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { + return true; + } + if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { + tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); + } + tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); + return true; + }) + .run(); + }, + ); + scrollTiptapCaretIntoView(editorInstance); + } + return true; + }; + const editor = useEditor( { extensions: richText ? RICH_TEXT_EXTENSIONS : PLAIN_TEXT_EXTENSIONS, @@ -1107,6 +1202,9 @@ function ComposerPromptEditorTiptapInner( isKnownModel: (instanceId, model) => modelKeys.has(modelMentionKey(instanceId, model)), }), editable: !disabled, + // handlePaste parses pasted markdown itself and tags the insert as a paste for undo grouping, so Tiptap's + // paste rules would parse that result a second time and turn literal `*` and `**` into marks. + enablePasteRules: false, editorProps: { attributes: editorAttributes, handleKeyDown: (view, event) => { @@ -1364,100 +1462,24 @@ function ComposerPromptEditorTiptapInner( handlePaste: (view, event) => { const clipboardData = event.clipboardData; if (!clipboardData || clipboardData.files.length > 0) return false; - const pastedText = clipboardData.getData("text/plain"); - if (!pastedText) return false; + if (!insertClipboardText(view, clipboardData)) return false; event.preventDefault(); - // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. - if (isInCodeBlock(view)) { - const { from, to } = view.state.selection; - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, from, to), - "paste", - ).scrollIntoView(), - ); - return true; - } - // Shown Markdown source is parsed when the caret leaves, so paste one line into it as text. - const sourceType = view.state.schema.marks[MARKDOWN_SOURCE_MARK]; - const { from: pasteFrom, to: pasteTo, $from: $pasteFrom } = view.state.selection; - if ( - sourceType?.isInSet($pasteFrom.marks()) && - !pastedText.includes("\n") && - collectComposerPromptInlineTokens(`${pastedText}\n`).length === 0 - ) { - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, pasteFrom, pasteTo), - "paste", - ).scrollIntoView(), - ); - return true; - } - const importFragment = readCallbacks().importContextFragment; - let text = - !literalText && importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; - const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); - const isChip = (token: (typeof tokens)[number] | undefined) => - token?.type === "mention" || - (token?.type === "skill" && skillLabelFor(token.value) !== null) || - (token?.type === "model" && isKnownModel(token.instanceId, token.model)); - const lastToken = tokens.at(-1); - if (isChip(lastToken) && lastToken?.end === text.length) { - text += " "; - } - if (isChip(tokens[0]) && tokens[0]?.start === 0) { - const map = serializeEditorDoc(view.state.doc); - const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); - if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; - } - 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 editorInstance = (view.dom as TiptapEditorHTMLElement).editor; - if (editorInstance) { - const $paste = view.state.selection.$from; - const nested = ["listItem", "taskItem", "blockquote"].some((name) => - hasAncestor($paste, name), - ); - insertMarkdownParagraphs( - text, - skillLabelFor, - { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, - (content) => { - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - // Pasted chips and lines would split shown Markdown, so parse it first. - .command(collapseShownMarkdownSource) - .insertContent(content) - .command(({ tr }) => { - // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line - // if needed. - const { selection } = tr; - if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { - return true; - } - if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { - tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); - } - tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); - return true; - }) - .run(); - }, - ); - scrollTiptapCaretIntoView(editorInstance); - } return true; }, + handleDrop: (view, event, _slice, moved) => { + const data = event.dataTransfer; + if (moved || view.dragging || !data || data.files.length > 0) return false; + // ProseMirror already parses an HTML drop into marks; only plain-text drops need Markdown parsing. + if (!data.getData("text/plain") || data.getData("text/html")) return false; + const target = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!target) return false; + view.dispatch( + view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(target.pos))), + ); + // Handling the drop skips ProseMirror's own focus call, so typing would go to the previous control. + view.focus(); + return insertClipboardText(view, data); + }, }, onUpdate: ({ editor: updated, transaction }) => { // Undo can restore a chip for a skill or model the environment no longer offers. diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 417397e88ccc5..256d46fc63ecb 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -16,14 +16,13 @@ import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; import { Heading } from "@tiptap/extension-heading"; import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; -import { Italic, starInputRegex, starPasteRegex } from "@tiptap/extension-italic"; +import { Italic, starInputRegex } from "@tiptap/extension-italic"; import { type Command, type Editor, Extension, InputRule, markInputRule, - markPasteRule, mergeAttributes, } from "@tiptap/core"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; @@ -309,9 +308,6 @@ export const ComposerItalicExtension = Italic.extend({ addInputRules() { return [markInputRuleOutsideCode(starInputRegex, this.type)]; }, - addPasteRules() { - return [markPasteRule({ find: starPasteRegex, type: this.type })]; - }, }); export const ComposerBoldExtension = Bold.extend({