| author | |
| committer | |
| log | d0dc45885c1441da5bd46dcf13124d94ed728b0a |
| tree | e7ba8789d2c7d730478e5ac5aad1c56f134c57d6 |
| parent | 1f2b10c25bc400461e6c114c2c7236e91996225c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>3 files changed, 170 insertions(+), 11 deletions(-)
apps/web/src/components/ComposerPromptEditorTiptap.tsx+22-9| ... | ... | @@ -4,7 +4,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tip |
| 4 | 4 | import StarterKit from "@tiptap/starter-kit"; |
| 5 | 5 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| 6 | 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; |
| 7 | import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; | |
| 7 | import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; | |
| 8 | 8 | import { Decoration, DecorationSet } from "@tiptap/pm/view"; |
| 9 | 9 | import type { |
| 10 | 10 | AssistantCitation, |
| ... | ... | @@ -47,6 +47,7 @@ import { |
| 47 | 47 | buildDocJson, |
| 48 | 48 | buildTiptapContent, |
| 49 | 49 | collapsedToFlat, |
| 50 | caretTakesMarksBefore, | |
| 50 | 51 | ComposerCodeExtension, |
| 51 | 52 | ComposerTaskItemExtension, |
| 52 | 53 | flatToCollapsed, |
| ... | ... | @@ -54,6 +55,7 @@ import { |
| 54 | 55 | flatToPm, |
| 55 | 56 | pmToFlat, |
| 56 | 57 | serializeEditorDoc, |
| 58 | stepCaretAcrossStyledEdge, | |
| 57 | 59 | type SkillMeta, |
| 58 | 60 | } from "~/composer-rich-text-doc"; |
| 59 | 61 | import { |
| ... | ... | @@ -521,9 +523,9 @@ const MarkerPluginKey = new PluginKey("composer-rich-markers"); |
| 521 | 523 | const ComposerMarkerPlugin = new Plugin({ |
| 522 | 524 | key: MarkerPluginKey, |
| 523 | 525 | state: { |
| 524 | init: (_, state) => decorationsForSelection(state.doc, state.selection), | |
| 525 | apply: (tr, old) => | |
| 526 | tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old, | |
| 526 | init: (_, state) => decorationsForSelection(state), | |
| 527 | apply: (tr, old, _, state) => | |
| 528 | tr.docChanged || tr.selectionSet || tr.storedMarksSet ? decorationsForSelection(state) : old, | |
| 527 | 529 | }, |
| 528 | 530 | props: { |
| 529 | 531 | decorations(state) { |
| ... | ... | @@ -532,10 +534,11 @@ const ComposerMarkerPlugin = new Plugin({ |
| 532 | 534 | }, |
| 533 | 535 | }); |
| 534 | 536 | |
| 535 | function decorationsForSelection( | |
| 536 | doc: ProseMirrorNode, | |
| 537 | selection: { from: number; to: number; empty: boolean }, | |
| 538 | ): DecorationSet { | |
| 537 | function decorationsForSelection(state: EditorState): DecorationSet { | |
| 538 | const { doc, selection } = state; | |
| 539 | // Markers at the caret render after it while it types with the marks before | |
| 540 | // the edge. Shifting keeps closers ahead of openers at a shared position. | |
| 541 | const caretSide = caretTakesMarksBefore(state) ? 3 : 0; | |
| 539 | 542 | const decorations: Decoration[] = []; |
| 540 | 543 | if (!selection.empty) { |
| 541 | 544 | doc.nodesBetween(selection.from, selection.to, (node, pos) => { |
| ... | ... | @@ -553,7 +556,8 @@ function decorationsForSelection( |
| 553 | 556 | ? selection.from >= range.from && selection.from <= range.to |
| 554 | 557 | : selection.from < range.to && selection.to > range.from; |
| 555 | 558 | if (!active) continue; |
| 556 | for (const { at, side, text } of range.markers) { | |
| 559 | for (const { at, side: baseSide, text } of range.markers) { | |
| 560 | const side = selection.empty && at === selection.from ? baseSide + caretSide : baseSide; | |
| 557 | 561 | const marker = document.createElement("span"); |
| 558 | 562 | marker.className = "composer-rich-marker"; |
| 559 | 563 | marker.textContent = text; |
| ... | ... | @@ -919,6 +923,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 919 | 923 | ) { |
| 920 | 924 | const { $from } = view.state.selection; |
| 921 | 925 | const direction = event.key === "ArrowLeft" ? -1 : 1; |
| 926 | // Take the other stop of a styled edge before skipping a chip, so | |
| 927 | // the plain stop between styled text and a chip stays reachable. | |
| 928 | const step = stepCaretAcrossStyledEdge(view.state, direction); | |
| 929 | if (step) { | |
| 930 | event.preventDefault(); | |
| 931 | event.stopPropagation(); | |
| 932 | view.dispatch(step); | |
| 933 | return true; | |
| 934 | } | |
| 922 | 935 | const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; |
| 923 | 936 | if (adjacent?.type.name.startsWith("composer-")) { |
| 924 | 937 | event.preventDefault(); |
apps/web/src/composer-rich-text-doc.test.ts+94| ... | ... | @@ -2,10 +2,12 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/ |
| 2 | 2 | import StarterKit from "@tiptap/starter-kit"; |
| 3 | 3 | import { TaskList } from "@tiptap/extension-task-list"; |
| 4 | 4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| 5 | import { EditorState, TextSelection } from "@tiptap/pm/state"; | |
| 5 | 6 | import { describe, expect, it } from "vite-plus/test"; |
| 6 | 7 | |
| 7 | 8 | import { |
| 8 | 9 | buildDocJson, |
| 10 | caretTakesMarksBefore, | |
| 9 | 11 | collapsedToFlat, |
| 10 | 12 | ComposerCodeExtension, |
| 11 | 13 | ComposerTaskItemExtension, |
| ... | ... | @@ -14,6 +16,7 @@ import { |
| 14 | 16 | flatToPm, |
| 15 | 17 | pmToFlat, |
| 16 | 18 | serializeEditorDoc, |
| 19 | stepCaretAcrossStyledEdge, | |
| 17 | 20 | } from "./composer-rich-text-doc"; |
| 18 | 21 | |
| 19 | 22 | function stubAtom(name: string, attrs: Record<string, { default: unknown }>) { |
| ... | ... | @@ -361,3 +364,94 @@ describe("composer rich text document model", () => { |
| 361 | 364 | expect(collapsedToFlat(map, 9)).toBe(6); |
| 362 | 365 | }); |
| 363 | 366 | }); |
| 367 | ||
| 368 | describe("caret stops at styled edges", () => { | |
| 369 | function stateAt(value: string, pmPos: number) { | |
| 370 | const doc = ProseMirrorNode.fromJSON( | |
| 371 | schema, | |
| 372 | buildDocJson(value, (name) => ({ label: name, description: null })), | |
| 373 | ); | |
| 374 | return EditorState.create({ doc, selection: TextSelection.create(doc, pmPos) }); | |
| 375 | } | |
| 376 | ||
| 377 | function typed(state: EditorState, text: string) { | |
| 378 | return serializeEditorDoc(state.apply(state.tr.insertText(text)).doc).value; | |
| 379 | } | |
| 380 | ||
| 381 | it("lets the caret step out in front of bold that starts the line", () => { | |
| 382 | const inside = stateAt("**bold** tail", 1); | |
| 383 | expect(caretTakesMarksBefore(inside)).toBe(false); | |
| 384 | expect(typed(inside, "x")).toBe("**xbold** tail"); | |
| 385 | ||
| 386 | const step = stepCaretAcrossStyledEdge(inside, -1); | |
| 387 | expect(step).not.toBeNull(); | |
| 388 | const outside = inside.apply(step!); | |
| 389 | expect(caretTakesMarksBefore(outside)).toBe(true); | |
| 390 | expect(typed(outside, "x")).toBe("x**bold** tail"); | |
| 391 | // Already outside: the next ArrowLeft moves the caret as usual. | |
| 392 | expect(stepCaretAcrossStyledEdge(outside, -1)).toBeNull(); | |
| 393 | ||
| 394 | const back = outside.apply(stepCaretAcrossStyledEdge(outside, 1)!); | |
| 395 | expect(typed(back, "x")).toBe("**xbold** tail"); | |
| 396 | }); | |
| 397 | ||
| 398 | it("lets the caret step out after bold that ends the line", () => { | |
| 399 | const inside = stateAt("head **bold**", 10); | |
| 400 | expect(caretTakesMarksBefore(inside)).toBe(true); | |
| 401 | expect(typed(inside, "x")).toBe("head **boldx**"); | |
| 402 | expect(stepCaretAcrossStyledEdge(inside, -1)).toBeNull(); | |
| 403 | ||
| 404 | const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); | |
| 405 | expect(caretTakesMarksBefore(outside)).toBe(false); | |
| 406 | expect(typed(outside, "x")).toBe("head **bold**x"); | |
| 407 | }); | |
| 408 | ||
| 409 | it("offers both stops where styled text meets plain text mid-line", () => { | |
| 410 | // Caret after "a ": types plain until ArrowRight steps into the bold. | |
| 411 | const plain = stateAt("a **b** c", 3); | |
| 412 | expect(caretTakesMarksBefore(plain)).toBe(true); | |
| 413 | expect(typed(plain, "x")).toBe("a x**b** c"); | |
| 414 | const bold = plain.apply(stepCaretAcrossStyledEdge(plain, 1)!); | |
| 415 | expect(typed(bold, "x")).toBe("a **xb** c"); | |
| 416 | }); | |
| 417 | ||
| 418 | it("offers the plain stop between bold text and a chip right after it", () => { | |
| 419 | // Markdown needs a space before a mention, but deleting it leaves them adjacent. | |
| 420 | const doc = schema.node("doc", null, [ | |
| 421 | schema.node("paragraph", null, [ | |
| 422 | schema.text("bold", [schema.marks.bold!.create()]), | |
| 423 | schema.nodes["composer-mention"]!.create({ path: "README.md", source: "@README.md" }), | |
| 424 | ]), | |
| 425 | ]); | |
| 426 | const inside = EditorState.create({ doc, selection: TextSelection.create(doc, 5) }); | |
| 427 | expect(caretTakesMarksBefore(inside)).toBe(true); | |
| 428 | const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); | |
| 429 | expect(caretTakesMarksBefore(outside)).toBe(false); | |
| 430 | const withText = outside.apply(outside.tr.insertText("x")).doc; | |
| 431 | expect(withText.child(0).child(1).text).toBe("x"); | |
| 432 | expect(withText.child(0).child(1).marks).toEqual([]); | |
| 433 | }); | |
| 434 | ||
| 435 | it("steps out of inline code at the end of a line without inserting a space", () => { | |
| 436 | expect(ComposerCodeExtension.config.exitable).toBe(false); | |
| 437 | const inside = stateAt("`code`", 5); | |
| 438 | const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!); | |
| 439 | expect(typed(outside, "x")).toBe("`code`x"); | |
| 440 | expect(stepCaretAcrossStyledEdge(outside, 1)).toBeNull(); | |
| 441 | }); | |
| 442 | ||
| 443 | it("keeps marks the user toggled at an edge and lets the arrow move", () => { | |
| 444 | const inside = stateAt("a **b** c", 3); | |
| 445 | const toggled = inside.apply( | |
| 446 | inside.tr.setStoredMarks([schema.marks.bold!.create(), schema.marks.italic!.create()]), | |
| 447 | ); | |
| 448 | expect(stepCaretAcrossStyledEdge(toggled, 1)).toBeNull(); | |
| 449 | expect(stepCaretAcrossStyledEdge(toggled, -1)).toBeNull(); | |
| 450 | }); | |
| 451 | ||
| 452 | it("leaves arrow keys alone away from styled edges", () => { | |
| 453 | expect(stepCaretAcrossStyledEdge(stateAt("**bold** tail", 3), -1)).toBeNull(); | |
| 454 | expect(stepCaretAcrossStyledEdge(stateAt("plain text", 1), -1)).toBeNull(); | |
| 455 | expect(caretTakesMarksBefore(stateAt("plain text", 1))).toBe(false); | |
| 456 | }); | |
| 457 | }); |
apps/web/src/composer-rich-text-doc.ts+54-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; | |
| 1 | import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model"; | |
| 2 | import type { EditorState, Transaction } from "@tiptap/pm/state"; | |
| 2 | 3 | import { Code } from "@tiptap/extension-code"; |
| 3 | 4 | import { TaskItem } from "@tiptap/extension-task-item"; |
| 4 | 5 | |
| ... | ... | @@ -47,7 +48,12 @@ const TIPTAP_TO_MARK: Record<string, RichTextMark> = { |
| 47 | 48 | * spans markdown like `**\`x\`**` parses into and drops the whole insert. |
| 48 | 49 | * Code nests inside emphasis here, so it only excludes itself like the rest. |
| 49 | 50 | */ |
| 50 | export const ComposerCodeExtension = Code.extend({ excludes: "code" }); | |
| 51 | export const ComposerCodeExtension = Code.extend({ | |
| 52 | excludes: "code", | |
| 53 | // ArrowRight leaves code through the caret stops at styled edges, so the | |
| 54 | // stock exit (inserting a space at the end of a line) is not needed. | |
| 55 | exitable: false, | |
| 56 | }); | |
| 51 | 57 | |
| 52 | 58 | /** |
| 53 | 59 | * 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 { |
| 650 | 656 | } |
| 651 | 657 | return Math.max(0, Math.min(best, map.docLength)); |
| 652 | 658 | } |
| 659 | ||
| 660 | // ── Caret stops at styled edges ──────────────────────────────────────────── | |
| 661 | // | |
| 662 | // Markers are decorations, not text, so the position where styled text meets | |
| 663 | // unstyled text (or a paragraph edge) is a single document position. The | |
| 664 | // caret gets two stops there: one that types with the marks before the edge | |
| 665 | // and one that types with the marks after it. Stored marks pick the stop, and | |
| 666 | // the revealed markers render on the matching side of the caret, so a pasted | |
| 667 | // `**bold**` at the start of a line can still be typed in front of. | |
| 668 | ||
| 669 | function styledEdge(state: EditorState) { | |
| 670 | const { selection } = state; | |
| 671 | if (!selection.empty) return null; | |
| 672 | const { $from } = selection; | |
| 673 | if (!$from.parent.inlineContent) return null; | |
| 674 | const before = $from.nodeBefore?.marks ?? Mark.none; | |
| 675 | const after = $from.nodeAfter?.marks ?? Mark.none; | |
| 676 | if (Mark.sameSet(before, after)) return null; | |
| 677 | return { before, after, current: state.storedMarks ?? $from.marks() }; | |
| 678 | } | |
| 679 | ||
| 680 | /** True when the caret sits on a styled edge and types with the marks before it. */ | |
| 681 | export function caretTakesMarksBefore(state: EditorState): boolean { | |
| 682 | const edge = styledEdge(state); | |
| 683 | return edge !== null && Mark.sameSet(edge.current, edge.before); | |
| 684 | } | |
| 685 | ||
| 686 | /** | |
| 687 | * Moves the caret to the other stop of the styled edge it sits on, toward | |
| 688 | * `direction`. Returns null when there is no stop to take, so the arrow key | |
| 689 | * moves the caret as usual. | |
| 690 | */ | |
| 691 | export function stepCaretAcrossStyledEdge( | |
| 692 | state: EditorState, | |
| 693 | direction: -1 | 1, | |
| 694 | ): Transaction | null { | |
| 695 | const edge = styledEdge(state); | |
| 696 | if (!edge) return null; | |
| 697 | // Marks the user toggled by hand (neither stop) are theirs: move as usual. | |
| 698 | if (!Mark.sameSet(edge.current, edge.before) && !Mark.sameSet(edge.current, edge.after)) { | |
| 699 | return null; | |
| 700 | } | |
| 701 | const target = direction === -1 ? edge.before : edge.after; | |
| 702 | if (Mark.sameSet(edge.current, target)) return null; | |
| 703 | return state.tr.setStoredMarks(target); | |
| 704 | } |