| 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,7 +4,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tip |
| 4 | import StarterKit from "@tiptap/starter-kit"; | 4 | import StarterKit from "@tiptap/starter-kit"; |
| 5 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; | 5 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; | 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 | import { Decoration, DecorationSet } from "@tiptap/pm/view"; | 8 | import { Decoration, DecorationSet } from "@tiptap/pm/view"; |
| 9 | import type { | 9 | import type { |
| 10 | AssistantCitation, | 10 | AssistantCitation, |
| ... | @@ -47,6 +47,7 @@ import { | ... | @@ -47,6 +47,7 @@ import { |
| 47 | buildDocJson, | 47 | buildDocJson, |
| 48 | buildTiptapContent, | 48 | buildTiptapContent, |
| 49 | collapsedToFlat, | 49 | collapsedToFlat, |
| 50 | caretTakesMarksBefore, | ||
| 50 | ComposerCodeExtension, | 51 | ComposerCodeExtension, |
| 51 | ComposerTaskItemExtension, | 52 | ComposerTaskItemExtension, |
| 52 | flatToCollapsed, | 53 | flatToCollapsed, |
| ... | @@ -54,6 +55,7 @@ import { | ... | @@ -54,6 +55,7 @@ import { |
| 54 | flatToPm, | 55 | flatToPm, |
| 55 | pmToFlat, | 56 | pmToFlat, |
| 56 | serializeEditorDoc, | 57 | serializeEditorDoc, |
| 58 | stepCaretAcrossStyledEdge, | ||
| 57 | type SkillMeta, | 59 | type SkillMeta, |
| 58 | } from "~/composer-rich-text-doc"; | 60 | } from "~/composer-rich-text-doc"; |
| 59 | import { | 61 | import { |
| ... | @@ -521,9 +523,9 @@ const MarkerPluginKey = new PluginKey("composer-rich-markers"); | ... | @@ -521,9 +523,9 @@ const MarkerPluginKey = new PluginKey("composer-rich-markers"); |
| 521 | const ComposerMarkerPlugin = new Plugin({ | 523 | const ComposerMarkerPlugin = new Plugin({ |
| 522 | key: MarkerPluginKey, | 524 | key: MarkerPluginKey, |
| 523 | state: { | 525 | state: { |
| 524 | init: (_, state) => decorationsForSelection(state.doc, state.selection), | 526 | init: (_, state) => decorationsForSelection(state), |
| 525 | apply: (tr, old) => | 527 | apply: (tr, old, _, state) => |
| 526 | tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old, | 528 | tr.docChanged || tr.selectionSet || tr.storedMarksSet ? decorationsForSelection(state) : old, |
| 527 | }, | 529 | }, |
| 528 | props: { | 530 | props: { |
| 529 | decorations(state) { | 531 | decorations(state) { |
| ... | @@ -532,10 +534,11 @@ const ComposerMarkerPlugin = new Plugin({ | ... | @@ -532,10 +534,11 @@ const ComposerMarkerPlugin = new Plugin({ |
| 532 | }, | 534 | }, |
| 533 | }); | 535 | }); |
| 534 | 536 | ||
| 535 | function decorationsForSelection( | 537 | function decorationsForSelection(state: EditorState): DecorationSet { |
| 536 | doc: ProseMirrorNode, | 538 | const { doc, selection } = state; |
| 537 | selection: { from: number; to: number; empty: boolean }, | 539 | // Markers at the caret render after it while it types with the marks before |
| 538 | ): DecorationSet { | 540 | // the edge. Shifting keeps closers ahead of openers at a shared position. |
| 541 | const caretSide = caretTakesMarksBefore(state) ? 3 : 0; | ||
| 539 | const decorations: Decoration[] = []; | 542 | const decorations: Decoration[] = []; |
| 540 | if (!selection.empty) { | 543 | if (!selection.empty) { |
| 541 | doc.nodesBetween(selection.from, selection.to, (node, pos) => { | 544 | doc.nodesBetween(selection.from, selection.to, (node, pos) => { |
| ... | @@ -553,7 +556,8 @@ function decorationsForSelection( | ... | @@ -553,7 +556,8 @@ function decorationsForSelection( |
| 553 | ? selection.from >= range.from && selection.from <= range.to | 556 | ? selection.from >= range.from && selection.from <= range.to |
| 554 | : selection.from < range.to && selection.to > range.from; | 557 | : selection.from < range.to && selection.to > range.from; |
| 555 | if (!active) continue; | 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 | const marker = document.createElement("span"); | 561 | const marker = document.createElement("span"); |
| 558 | marker.className = "composer-rich-marker"; | 562 | marker.className = "composer-rich-marker"; |
| 559 | marker.textContent = text; | 563 | marker.textContent = text; |
| ... | @@ -919,6 +923,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -919,6 +923,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 919 | ) { | 923 | ) { |
| 920 | const { $from } = view.state.selection; | 924 | const { $from } = view.state.selection; |
| 921 | const direction = event.key === "ArrowLeft" ? -1 : 1; | 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 | const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; | 935 | const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; |
| 923 | if (adjacent?.type.name.startsWith("composer-")) { | 936 | if (adjacent?.type.name.startsWith("composer-")) { |
| 924 | event.preventDefault(); | 937 | event.preventDefault(); |
apps/web/src/composer-rich-text-doc.test.ts+94| ... | @@ -2,10 +2,12 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/ | ... | @@ -2,10 +2,12 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/ |
| 2 | import StarterKit from "@tiptap/starter-kit"; | 2 | import StarterKit from "@tiptap/starter-kit"; |
| 3 | import { TaskList } from "@tiptap/extension-task-list"; | 3 | import { TaskList } from "@tiptap/extension-task-list"; |
| 4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model"; | 4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| 5 | import { EditorState, TextSelection } from "@tiptap/pm/state"; | ||
| 5 | import { describe, expect, it } from "vite-plus/test"; | 6 | import { describe, expect, it } from "vite-plus/test"; |
| 6 | 7 | ||
| 7 | import { | 8 | import { |
| 8 | buildDocJson, | 9 | buildDocJson, |
| 10 | caretTakesMarksBefore, | ||
| 9 | collapsedToFlat, | 11 | collapsedToFlat, |
| 10 | ComposerCodeExtension, | 12 | ComposerCodeExtension, |
| 11 | ComposerTaskItemExtension, | 13 | ComposerTaskItemExtension, |
| ... | @@ -14,6 +16,7 @@ import { | ... | @@ -14,6 +16,7 @@ import { |
| 14 | flatToPm, | 16 | flatToPm, |
| 15 | pmToFlat, | 17 | pmToFlat, |
| 16 | serializeEditorDoc, | 18 | serializeEditorDoc, |
| 19 | stepCaretAcrossStyledEdge, | ||
| 17 | } from "./composer-rich-text-doc"; | 20 | } from "./composer-rich-text-doc"; |
| 18 | 21 | ||
| 19 | function stubAtom(name: string, attrs: Record<string, { default: unknown }>) { | 22 | function stubAtom(name: string, attrs: Record<string, { default: unknown }>) { |
| ... | @@ -361,3 +364,94 @@ describe("composer rich text document model", () => { | ... | @@ -361,3 +364,94 @@ describe("composer rich text document model", () => { |
| 361 | expect(collapsedToFlat(map, 9)).toBe(6); | 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,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 | import { Code } from "@tiptap/extension-code"; | 3 | import { Code } from "@tiptap/extension-code"; |
| 3 | import { TaskItem } from "@tiptap/extension-task-item"; | 4 | import { TaskItem } from "@tiptap/extension-task-item"; |
| 4 | 5 | ||
| ... | @@ -47,7 +48,12 @@ const TIPTAP_TO_MARK: Record<string, RichTextMark> = { | ... | @@ -47,7 +48,12 @@ const TIPTAP_TO_MARK: Record<string, RichTextMark> = { |
| 47 | * spans markdown like `**\`x\`**` parses into and drops the whole insert. | 48 | * spans markdown like `**\`x\`**` parses into and drops the whole insert. |
| 48 | * Code nests inside emphasis here, so it only excludes itself like the rest. | 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 | * Task list items keep their exact source indent in an attribute so nesting | 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,3 +656,49 @@ export function pmToFlat(map: RichDocMap, pmPos: number): number { |
| 650 | } | 656 | } |
| 651 | return Math.max(0, Math.min(best, map.docLength)); | 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 | } |