diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 0fff87e76851..4fd3022e6d8d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -62,7 +62,7 @@ import { groupUndoByChangeKind, markAsClipboardEdit, } from "~/composer-undo-grouping"; -import { setTypingMarksAfterPaste } from "~/composer-paste-marks"; +import { ComposerTypingMarksExtension } from "~/composer-typing-marks"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; @@ -833,6 +833,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerMarkersExtension, ...(richText ? [ + ComposerTypingMarksExtension, ComposerCodeExtension, TaskList, ComposerTaskItemExtension.extend({ @@ -1073,10 +1074,6 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { .insertContent(content) .run(); }); - if (richText) { - const resetMarks = setTypingMarksAfterPaste(editorInstance.state); - if (resetMarks) editorInstance.view.dispatch(resetMarks); - } scrollTiptapCaretIntoView(editorInstance); } return true; diff --git a/apps/web/src/composer-paste-marks.test.ts b/apps/web/src/composer-paste-marks.test.ts deleted file mode 100644 index e8b4b57242e8..000000000000 --- a/apps/web/src/composer-paste-marks.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Schema } from "@tiptap/pm/model"; -import { EditorState, TextSelection } from "@tiptap/pm/state"; -import { describe, expect, it } from "vite-plus/test"; - -import { serializeEditorDoc } from "./composer-rich-text-doc"; -import { setTypingMarksAfterPaste } from "./composer-paste-marks"; - -const schema = new Schema({ - nodes: { - doc: { content: "block+" }, - paragraph: { content: "inline*", group: "block" }, - text: { group: "inline" }, - }, - marks: { bold: {}, italic: {} }, -}); - -describe("typing after pasted formatting", () => { - it("continues in plain text after a bold paste at the end of a line", () => { - const doc = schema.node("doc", null, [ - schema.node("paragraph", null, [schema.text("bold", [schema.marks.bold!.create()])]), - ]); - const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5) }); - - expect(serializeEditorDoc(state.apply(state.tr.insertText(" plain")).doc).value).toBe( - "**bold plain**", - ); - const continued = state.apply(setTypingMarksAfterPaste(state)!); - expect(serializeEditorDoc(continued.apply(continued.tr.insertText(" plain")).doc).value).toBe( - "**bold** plain", - ); - }); - - it("uses the formatting of following text when pasting in the middle", () => { - const doc = schema.node("doc", null, [ - schema.node("paragraph", null, [ - schema.text("bold", [schema.marks.bold!.create()]), - schema.text("tail", [schema.marks.italic!.create()]), - ]), - ]); - const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5) }); - const continued = state.apply(setTypingMarksAfterPaste(state)!); - const withText = continued.apply(continued.tr.insertText("x")).doc; - - expect(withText.child(0).child(1).text).toBe("xtail"); - expect( - withText - .child(0) - .child(1) - .marks.map((mark) => mark.type.name), - ).toEqual(["italic"]); - }); -}); diff --git a/apps/web/src/composer-paste-marks.ts b/apps/web/src/composer-paste-marks.ts deleted file mode 100644 index 9517bc5698d1..000000000000 --- a/apps/web/src/composer-paste-marks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { EditorState, Transaction } from "@tiptap/pm/state"; - -/** Continue after pasted formatting with the text on the right, or plain at a line end. */ -export function setTypingMarksAfterPaste(state: EditorState): Transaction | null { - if (!state.selection.empty) return null; - return state.tr.setStoredMarks(state.selection.$from.nodeAfter?.marks ?? []); -} diff --git a/apps/web/src/composer-typing-marks.test.ts b/apps/web/src/composer-typing-marks.test.ts new file mode 100644 index 000000000000..e6026874a2d0 --- /dev/null +++ b/apps/web/src/composer-typing-marks.test.ts @@ -0,0 +1,163 @@ +import { getSchema } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { history, redo, undo } from "@tiptap/pm/history"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildDocJson, ComposerCodeExtension, serializeEditorDoc } from "./composer-rich-text-doc"; +import { createComposerTypingMarksPlugin } from "./composer-typing-marks"; + +const schema = getSchema([StarterKit.configure({ code: false }), ComposerCodeExtension]); + +function createState(value: string, cursor: number) { + const doc = schema.nodeFromJSON(buildDocJson(value, () => ({ label: "", description: null }))); + return EditorState.create({ + doc, + selection: TextSelection.create(doc, cursor), + plugins: [createComposerTypingMarksPlugin(), history()], + }); +} + +function move(state: EditorState, cursor: number) { + return state.applyTransaction(state.tr.setSelection(TextSelection.create(state.doc, cursor))) + .state; +} + +function type(state: EditorState, text: string) { + for (const character of text) + state = state.applyTransaction(state.tr.insertText(character)).state; + return state; +} + +function value(state: EditorState) { + return serializeEditorDoc(state.doc).value; +} + +describe("composer typing marks after moving the caret", () => { + it.each(["**b**", "*b*", "~~b~~", "`b`", "***b***", "**`b`**"])( + "types plain text after navigating away from and back to %s", + (markdown) => { + let state = createState(markdown, 2); + state = state.applyTransaction(state.tr.setStoredMarks([])).state; + state = move(move(state, 1), 2); + expect(value(type(state, " plain"))).toBe(`${markdown} plain`); + }, + ); + + it("covers caret placement after a controlled rewrite, including an unchanged position", () => { + let state = createState("x", 2); + const content = schema.nodeFromJSON( + buildDocJson("**b**", () => ({ label: "", description: null })), + ); + state = state.applyTransaction( + state.tr.replaceWith(0, state.doc.content.size, content.content), + ).state; + state = move(state, 2); + expect(value(type(state, " plain"))).toBe("**b** plain"); + }); + + it("resets typing marks after a paste and subsequent navigation", () => { + let state = createState("", 1); + const content = schema.nodeFromJSON( + buildDocJson("**b**", () => ({ label: "", description: null })), + ); + state = state.applyTransaction( + state.tr.replaceSelectionWith(content.firstChild!, false).setMeta("uiEvent", "paste"), + ).state; + expect(value(type(state, " plain"))).toBe("**b** plain"); + state = move(move(state, 1), 2); + expect(value(type(state, " plain"))).toBe("**b** plain"); + }); + + it("continues the following span after pasting in the middle of a line", () => { + let state = createState("tail", 1); + state = state.applyTransaction( + state.tr + .insertText("b") + .addMark(1, 2, schema.marks.bold!.create()) + .setMeta("uiEvent", "paste"), + ).state; + expect(value(type(state, "xy"))).toBe("**b**xytail"); + }); + + it("types plain after cutting back to a closing boundary", () => { + let state = createState("**b** plain", 8); + state = state.applyTransaction( + state.tr + .setSelection(TextSelection.create(state.doc, 2, 8)) + // Tiptap's deleteSelection command uses deleteRange without ensureMarks. + .deleteRange(2, 8) + .setMeta("uiEvent", "cut"), + ).state; + expect(value(state)).toBe("**b**"); + expect(value(type(state, " plain"))).toBe("**b** plain"); + }); + + it("preserves formatting while typing inside a span", () => { + const state = move(createState("**bold**", 5), 3); + expect(value(type(state, "xy"))).toBe("**boxyld**"); + }); + + it("adopts following text marks at a boundary", () => { + const state = move(createState("**b***tail*", 6), 2); + expect(value(type(state, "xy"))).toBe("**b***xytail*"); + }); + + it("matches the opening marker when placing the caret before formatted text", () => { + const state = move(createState("x **bold**", 7), 3); + expect(value(type(state, "xy"))).toBe("x **xybold**"); + }); + + it("preserves explicit formatting on and off over several keystrokes", () => { + let state = move(createState("**b**", 2), 2); + state = state.applyTransaction(state.tr.addStoredMark(schema.marks.bold!.create())).state; + state = type(state, "xy"); + expect(value(state)).toBe("**bxy**"); + state = state.applyTransaction(state.tr.removeStoredMark(schema.marks.bold!)).state; + expect(value(type(state, " plain"))).toBe("**bxy** plain"); + }); + + it("preserves an explicit mark decision in a selection transaction", () => { + let state = createState("**b**", 1); + state = state.applyTransaction( + state.tr + .setSelection(TextSelection.create(state.doc, 2)) + .addStoredMark(schema.marks.italic!.create()), + ).state; + expect(value(type(state, "xy"))).toBe("**b*xy***"); + }); + + it("does not alter ranged selection replacement", () => { + let state = createState("**bold**", 5); + state = state.applyTransaction( + state.tr.setSelection(TextSelection.create(state.doc, 2, 4)), + ).state; + expect(value(type(state, "x"))).toBe("**bxd**"); + }); + + it("leaves normal typing transactions alone", () => { + const state = createState("**b**", 2); + expect(value(type(state, "xy"))).toBe("**bxy**"); + expect(state.applyTransaction(state.tr.scrollIntoView()).transactions).toHaveLength(1); + }); + + it("adds no undo step and preserves undo/redo content and selection", () => { + let state = move(move(createState("**b**", 2), 1), 2); + state = type(state, " plain"); + expect( + undo(state, (transaction) => { + state = state.applyTransaction(transaction).state; + }), + ).toBe(true); + expect(value(state)).toBe("**b**"); + expect(state.selection.from).toBe(2); + expect(value(type(state, " again"))).toBe("**b** again"); + expect( + redo(state, (transaction) => { + state = state.applyTransaction(transaction).state; + }), + ).toBe(true); + expect(value(state)).toBe("**b** plain"); + expect(state.selection.from).toBe(8); + }); +}); diff --git a/apps/web/src/composer-typing-marks.ts b/apps/web/src/composer-typing-marks.ts new file mode 100644 index 000000000000..7607f1b961ce --- /dev/null +++ b/apps/web/src/composer-typing-marks.ts @@ -0,0 +1,38 @@ +import { Extension } from "@tiptap/core"; +import { isHistoryTransaction } from "@tiptap/pm/history"; +import { Mark } from "@tiptap/pm/model"; +import { Plugin } from "@tiptap/pm/state"; + +/** + * Follow the text on the right when moving the caret, or type plain at a line end. + * Keep inclusive marks during edits so explicit formatting shortcuts continue typing. + */ +export function createComposerTypingMarksPlugin() { + return new Plugin({ + appendTransaction(transactions, _oldState, state) { + if ( + !state.selection.empty || + state.storedMarks !== null || + !transactions.some( + (transaction) => + (transaction.selectionSet && !transaction.docChanged) || + isHistoryTransaction(transaction) || + transaction.getMeta("uiEvent") === "paste" || + transaction.getMeta("uiEvent") === "cut", + ) + ) { + return null; + } + const marks = state.selection.$from.nodeAfter?.marks ?? []; + if (Mark.sameSet(marks, state.selection.$from.marks())) return null; + return state.tr.setStoredMarks(marks); + }, + }); +} + +export const ComposerTypingMarksExtension = Extension.create({ + name: "composer-typing-marks", + addProseMirrorPlugins() { + return [createComposerTypingMarksPlugin()]; + }, +});