authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-10-06 20:19:28+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 04:19:28-05:00
logd0dc45885c1441da5bd46dcf13124d94ed728b0a
treee7ba8789d2c7d730478e5ac5aad1c56f134c57d6
parent1f2b10c25bc400461e6c114c2c7236e91996225c
signature Signed by PGP key B5690EEEBB952194

fix(web): type in front of bold that starts a composer line (#13217)

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
44import StarterKit from "@tiptap/starter-kit";
55import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
66import { splitBlockKeepMarks } from "@tiptap/pm/commands";
7import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
7import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
88import { Decoration, DecorationSet } from "@tiptap/pm/view";
99import type {
1010 AssistantCitation,
......@@ -47,6 +47,7 @@ import {
4747 buildDocJson,
4848 buildTiptapContent,
4949 collapsedToFlat,
50 caretTakesMarksBefore,
5051 ComposerCodeExtension,
5152 ComposerTaskItemExtension,
5253 flatToCollapsed,
......@@ -54,6 +55,7 @@ import {
5455 flatToPm,
5556 pmToFlat,
5657 serializeEditorDoc,
58 stepCaretAcrossStyledEdge,
5759 type SkillMeta,
5860} from "~/composer-rich-text-doc";
5961import {
......@@ -521,9 +523,9 @@ const MarkerPluginKey = new PluginKey("composer-rich-markers");
521523const ComposerMarkerPlugin = new Plugin({
522524 key: MarkerPluginKey,
523525 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,
527529 },
528530 props: {
529531 decorations(state) {
......@@ -532,10 +534,11 @@ const ComposerMarkerPlugin = new Plugin({
532534 },
533535});
534536
535function decorationsForSelection(
536 doc: ProseMirrorNode,
537 selection: { from: number; to: number; empty: boolean },
538): DecorationSet {
537function 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;
539542 const decorations: Decoration[] = [];
540543 if (!selection.empty) {
541544 doc.nodesBetween(selection.from, selection.to, (node, pos) => {
......@@ -553,7 +556,8 @@ function decorationsForSelection(
553556 ? selection.from >= range.from && selection.from <= range.to
554557 : selection.from < range.to && selection.to > range.from;
555558 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;
557561 const marker = document.createElement("span");
558562 marker.className = "composer-rich-marker";
559563 marker.textContent = text;
......@@ -919,6 +923,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
919923 ) {
920924 const { $from } = view.state.selection;
921925 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 }
922935 const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter;
923936 if (adjacent?.type.name.startsWith("composer-")) {
924937 event.preventDefault();
apps/web/src/composer-rich-text-doc.test.ts+94
......@@ -2,10 +2,12 @@ import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/
22import StarterKit from "@tiptap/starter-kit";
33import { TaskList } from "@tiptap/extension-task-list";
44import { Node as ProseMirrorNode } from "@tiptap/pm/model";
5import { EditorState, TextSelection } from "@tiptap/pm/state";
56import { describe, expect, it } from "vite-plus/test";
67
78import {
89 buildDocJson,
10 caretTakesMarksBefore,
911 collapsedToFlat,
1012 ComposerCodeExtension,
1113 ComposerTaskItemExtension,
......@@ -14,6 +16,7 @@ import {
1416 flatToPm,
1517 pmToFlat,
1618 serializeEditorDoc,
19 stepCaretAcrossStyledEdge,
1720} from "./composer-rich-text-doc";
1821
1922function stubAtom(name: string, attrs: Record<string, { default: unknown }>) {
......@@ -361,3 +364,94 @@ describe("composer rich text document model", () => {
361364 expect(collapsedToFlat(map, 9)).toBe(6);
362365 });
363366});
367
368describe("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 @@
1import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
1import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model";
2import type { EditorState, Transaction } from "@tiptap/pm/state";
23import { Code } from "@tiptap/extension-code";
34import { TaskItem } from "@tiptap/extension-task-item";
45
......@@ -47,7 +48,12 @@ const TIPTAP_TO_MARK: Record<string, RichTextMark> = {
4748 * spans markdown like `**\`x\`**` parses into and drops the whole insert.
4849 * Code nests inside emphasis here, so it only excludes itself like the rest.
4950 */
50export const ComposerCodeExtension = Code.extend({ excludes: "code" });
51export 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});
5157
5258/**
5359 * 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 {
650656 }
651657 return Math.max(0, Math.min(best, map.docLength));
652658}
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
669function 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. */
681export 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 */
691export 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}