Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<p><strong>bold</strong></p>");
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);
Expand Down
204 changes: 113 additions & 91 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 1 addition & 5 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
Loading