diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1926e4dd279f29851c9e282a06b35ffc6a16fb8d..6b7eb7df7066e0445229f2ec3ca8ab87bcf3a1ad 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 { @@ -521,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) { @@ -532,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) => { @@ -553,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; @@ -919,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 8fa311b1999c179bb6ad1e6b9c177afdd4c64b71..44f8e6d1f6d1a388964afc1ee50a6bf3a577576b 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -2,10 +2,12 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/ import StarterKit from "@tiptap/starter-kit"; import { TaskList } from "@tiptap/extension-task-list"; 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 +16,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + stepCaretAcrossStyledEdge, } from "./composer-rich-text-doc"; function stubAtom(name: string, attrs: Record) { @@ -361,3 +364,94 @@ 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; + } + + 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 ed83c35b5f5be4e03b58ab5e8d1768dc6a446ea3..49a489c82ad4850aa1c102c3f0f7120f112845f9 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); +}