diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4fd3022e6d8d..6b7eb7df7066 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -4,7 +4,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tip import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { splitBlockKeepMarks } from "@tiptap/pm/commands"; -import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import type { AssistantCitation, @@ -47,6 +47,7 @@ import { buildDocJson, buildTiptapContent, collapsedToFlat, + caretTakesMarksBefore, ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, @@ -54,6 +55,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + stepCaretAcrossStyledEdge, type SkillMeta, } from "~/composer-rich-text-doc"; import { @@ -62,7 +64,6 @@ import { groupUndoByChangeKind, markAsClipboardEdit, } from "~/composer-undo-grouping"; -import { ComposerTypingMarksExtension } from "~/composer-typing-marks"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; @@ -522,9 +523,9 @@ const MarkerPluginKey = new PluginKey("composer-rich-markers"); const ComposerMarkerPlugin = new Plugin({ key: MarkerPluginKey, state: { - init: (_, state) => decorationsForSelection(state.doc, state.selection), - apply: (tr, old) => - tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old, + init: (_, state) => decorationsForSelection(state), + apply: (tr, old, _, state) => + tr.docChanged || tr.selectionSet || tr.storedMarksSet ? decorationsForSelection(state) : old, }, props: { decorations(state) { @@ -533,10 +534,11 @@ const ComposerMarkerPlugin = new Plugin({ }, }); -function decorationsForSelection( - doc: ProseMirrorNode, - selection: { from: number; to: number; empty: boolean }, -): DecorationSet { +function decorationsForSelection(state: EditorState): DecorationSet { + const { doc, selection } = state; + // Markers at the caret render after it while it types with the marks before + // the edge. Shifting keeps closers ahead of openers at a shared position. + const caretSide = caretTakesMarksBefore(state) ? 3 : 0; const decorations: Decoration[] = []; if (!selection.empty) { doc.nodesBetween(selection.from, selection.to, (node, pos) => { @@ -554,7 +556,8 @@ function decorationsForSelection( ? selection.from >= range.from && selection.from <= range.to : selection.from < range.to && selection.to > range.from; if (!active) continue; - for (const { at, side, text } of range.markers) { + for (const { at, side: baseSide, text } of range.markers) { + const side = selection.empty && at === selection.from ? baseSide + caretSide : baseSide; const marker = document.createElement("span"); marker.className = "composer-rich-marker"; marker.textContent = text; @@ -833,7 +836,6 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerMarkersExtension, ...(richText ? [ - ComposerTypingMarksExtension, ComposerCodeExtension, TaskList, ComposerTaskItemExtension.extend({ @@ -921,6 +923,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { const { $from } = view.state.selection; const direction = event.key === "ArrowLeft" ? -1 : 1; + // Take the other stop of a styled edge before skipping a chip, so + // the plain stop between styled text and a chip stays reachable. + const step = stepCaretAcrossStyledEdge(view.state, direction); + if (step) { + event.preventDefault(); + event.stopPropagation(); + view.dispatch(step); + return true; + } const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; if (adjacent?.type.name.startsWith("composer-")) { event.preventDefault(); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 8fa311b1999c..41b58277ceaa 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -1,11 +1,14 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import { TaskList } from "@tiptap/extension-task-list"; +import { history, redo, undo, undoDepth } from "@tiptap/pm/history"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, + caretTakesMarksBefore, collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, @@ -14,6 +17,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + stepCaretAcrossStyledEdge, } from "./composer-rich-text-doc"; function stubAtom(name: string, attrs: Record) { @@ -361,3 +365,169 @@ describe("composer rich text document model", () => { expect(collapsedToFlat(map, 9)).toBe(6); }); }); + +describe("caret stops at styled edges", () => { + function stateAt(value: string, pmPos: number) { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(value, (name) => ({ label: name, description: null })), + ); + return EditorState.create({ doc, selection: TextSelection.create(doc, pmPos) }); + } + + function typed(state: EditorState, text: string) { + return serializeEditorDoc(state.apply(state.tr.insertText(text)).doc).value; + } + + function typeCharacters(state: EditorState, text: string) { + for (const character of text) state = state.apply(state.tr.insertText(character)); + return state; + } + + it("keeps typing plain after stepping outside pasted bold", () => { + let state = stateAt("", 1); + state = state.apply( + state.tr + .replaceSelectionWith(schema.text("bold", [schema.marks.bold!.create()]), false) + .setMeta("uiEvent", "paste"), + ); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + state = typeCharacters(state, " plain"); + expect(serializeEditorDoc(state.doc).value).toBe("**bold** plain"); + }); + + it("can step outside bold after cutting its trailing plain text", () => { + let state = stateAt("**b** plain", 8); + state = state.apply( + state.tr + .setSelection(TextSelection.create(state.doc, 2, 8)) + .deleteRange(2, 8) + .setMeta("uiEvent", "cut"), + ); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + state = typeCharacters(state, " again"); + expect(serializeEditorDoc(state.doc).value).toBe("**b** again"); + }); + + it("keeps caret steps out of history and can recover either stop after undo", () => { + let state = stateAt("**b**", 2).reconfigure({ plugins: [history()] }); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + expect(undoDepth(state)).toBe(0); + state = typeCharacters(state, " plain"); + expect(undoDepth(state)).toBe(1); + + expect( + undo(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + expect(serializeEditorDoc(state.doc).value).toBe("**b**"); + expect(state.selection.from).toBe(2); + expect(undoDepth(state)).toBe(0); + + expect( + redo(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + expect(serializeEditorDoc(state.doc).value).toBe("**b** plain"); + expect(state.selection.from).toBe(8); + + expect( + undo(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + expect(typed(state, "x")).toBe("**bx**"); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + expect(typed(state, "x")).toBe("**b**x"); + }); + + it("sustains explicit formatting toggles across several characters", () => { + let state = stateAt("**b**", 2); + state = state.apply(stepCaretAcrossStyledEdge(state, 1)!); + state = state.apply(state.tr.addStoredMark(schema.marks.bold!.create())); + state = typeCharacters(state, "xy"); + expect(serializeEditorDoc(state.doc).value).toBe("**bxy**"); + state = state.apply(state.tr.removeStoredMark(schema.marks.bold!)); + state = typeCharacters(state, " plain"); + expect(serializeEditorDoc(state.doc).value).toBe("**bxy** plain"); + }); + + it("lets the caret step out in front of bold that starts the line", () => { + const inside = stateAt("**bold** tail", 1); + expect(caretTakesMarksBefore(inside)).toBe(false); + expect(typed(inside, "x")).toBe("**xbold** tail"); + + const step = stepCaretAcrossStyledEdge(inside, -1); + expect(step).not.toBeNull(); + const outside = inside.apply(step!); + expect(caretTakesMarksBefore(outside)).toBe(true); + expect(typed(outside, "x")).toBe("x**bold** tail"); + // Already outside: the next ArrowLeft moves the caret as usual. + expect(stepCaretAcrossStyledEdge(outside, -1)).toBeNull(); + + const back = outside.apply(stepCaretAcrossStyledEdge(outside, 1)!); + expect(typed(back, "x")).toBe("**xbold** tail"); + }); + + it("lets the caret step out after bold that ends the line", () => { + const inside = stateAt("head **bold**", 10); + expect(caretTakesMarksBefore(inside)).toBe(true); + expect(typed(inside, "x")).toBe("head **boldx**"); + expect(stepCaretAcrossStyledEdge(inside, -1)).toBeNull(); + + const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); + expect(caretTakesMarksBefore(outside)).toBe(false); + expect(typed(outside, "x")).toBe("head **bold**x"); + }); + + it("offers both stops where styled text meets plain text mid-line", () => { + // Caret after "a ": types plain until ArrowRight steps into the bold. + const plain = stateAt("a **b** c", 3); + expect(caretTakesMarksBefore(plain)).toBe(true); + expect(typed(plain, "x")).toBe("a x**b** c"); + const bold = plain.apply(stepCaretAcrossStyledEdge(plain, 1)!); + expect(typed(bold, "x")).toBe("a **xb** c"); + }); + + it("offers the plain stop between bold text and a chip right after it", () => { + // Markdown needs a space before a mention, but deleting it leaves them adjacent. + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("bold", [schema.marks.bold!.create()]), + schema.nodes["composer-mention"]!.create({ path: "README.md", source: "@README.md" }), + ]), + ]); + const inside = EditorState.create({ doc, selection: TextSelection.create(doc, 5) }); + expect(caretTakesMarksBefore(inside)).toBe(true); + const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); + expect(caretTakesMarksBefore(outside)).toBe(false); + const withText = outside.apply(outside.tr.insertText("x")).doc; + expect(withText.child(0).child(1).text).toBe("x"); + expect(withText.child(0).child(1).marks).toEqual([]); + }); + + it("steps out of inline code at the end of a line without inserting a space", () => { + expect(ComposerCodeExtension.config.exitable).toBe(false); + const inside = stateAt("`code`", 5); + const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); + expect(typed(outside, "x")).toBe("`code`x"); + expect(stepCaretAcrossStyledEdge(outside, 1)).toBeNull(); + }); + + it("keeps marks the user toggled at an edge and lets the arrow move", () => { + const inside = stateAt("a **b** c", 3); + const toggled = inside.apply( + inside.tr.setStoredMarks([schema.marks.bold!.create(), schema.marks.italic!.create()]), + ); + expect(stepCaretAcrossStyledEdge(toggled, 1)).toBeNull(); + expect(stepCaretAcrossStyledEdge(toggled, -1)).toBeNull(); + }); + + it("leaves arrow keys alone away from styled edges", () => { + expect(stepCaretAcrossStyledEdge(stateAt("**bold** tail", 3), -1)).toBeNull(); + expect(stepCaretAcrossStyledEdge(stateAt("plain text", 1), -1)).toBeNull(); + expect(caretTakesMarksBefore(stateAt("plain text", 1))).toBe(false); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index ed83c35b5f5b..49a489c82ad4 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,4 +1,5 @@ -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; import { Code } from "@tiptap/extension-code"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -47,7 +48,12 @@ const TIPTAP_TO_MARK: Record = { * spans markdown like `**\`x\`**` parses into and drops the whole insert. * Code nests inside emphasis here, so it only excludes itself like the rest. */ -export const ComposerCodeExtension = Code.extend({ excludes: "code" }); +export const ComposerCodeExtension = Code.extend({ + excludes: "code", + // ArrowRight leaves code through the caret stops at styled edges, so the + // stock exit (inserting a space at the end of a line) is not needed. + exitable: false, +}); /** * Task list items keep their exact source indent in an attribute so nesting @@ -650,3 +656,49 @@ export function pmToFlat(map: RichDocMap, pmPos: number): number { } return Math.max(0, Math.min(best, map.docLength)); } + +// ── Caret stops at styled edges ──────────────────────────────────────────── +// +// Markers are decorations, not text, so the position where styled text meets +// unstyled text (or a paragraph edge) is a single document position. The +// caret gets two stops there: one that types with the marks before the edge +// and one that types with the marks after it. Stored marks pick the stop, and +// the revealed markers render on the matching side of the caret, so a pasted +// `**bold**` at the start of a line can still be typed in front of. + +function styledEdge(state: EditorState) { + const { selection } = state; + if (!selection.empty) return null; + const { $from } = selection; + if (!$from.parent.inlineContent) return null; + const before = $from.nodeBefore?.marks ?? Mark.none; + const after = $from.nodeAfter?.marks ?? Mark.none; + if (Mark.sameSet(before, after)) return null; + return { before, after, current: state.storedMarks ?? $from.marks() }; +} + +/** True when the caret sits on a styled edge and types with the marks before it. */ +export function caretTakesMarksBefore(state: EditorState): boolean { + const edge = styledEdge(state); + return edge !== null && Mark.sameSet(edge.current, edge.before); +} + +/** + * Moves the caret to the other stop of the styled edge it sits on, toward + * `direction`. Returns null when there is no stop to take, so the arrow key + * moves the caret as usual. + */ +export function stepCaretAcrossStyledEdge( + state: EditorState, + direction: -1 | 1, +): Transaction | null { + const edge = styledEdge(state); + if (!edge) return null; + // Marks the user toggled by hand (neither stop) are theirs: move as usual. + if (!Mark.sameSet(edge.current, edge.before) && !Mark.sameSet(edge.current, edge.after)) { + return null; + } + const target = direction === -1 ? edge.before : edge.after; + if (Mark.sameSet(edge.current, target)) return null; + return state.tr.setStoredMarks(target); +} diff --git a/apps/web/src/composer-typing-marks.test.ts b/apps/web/src/composer-typing-marks.test.ts deleted file mode 100644 index e6026874a2d0..000000000000 --- a/apps/web/src/composer-typing-marks.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index 7607f1b961ce..000000000000 --- a/apps/web/src/composer-typing-marks.ts +++ /dev/null @@ -1,38 +0,0 @@ -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()]; - }, -});