authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-14 18:09:01-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 18:09:01-05:00
log7bdfed98d733d1bcda99f43d43b24cd6913f2fce
tree5e077b02ebb9423525875ec12d67b34599321519
parent39aeaf404ad6d923f1bb7de4ec61549559d356ff
signature Signed by PGP key B5690EEEBB952194

fix(web): keep vertical cursor stops outside styled markers (#19651)


2 files changed, 142 insertions(+), 4 deletions(-)

apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx created+126
......@@ -0,0 +1,126 @@
1// @vitest-environment jsdom
2
3import type { Editor } from "@tiptap/core";
4import { TextSelection } from "@tiptap/pm/state";
5import { act, createRef } from "react";
6import { createRoot, type Root } from "react-dom/client";
7import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
8
9import { serializeEditorDoc } from "~/composer-rich-text-doc";
10import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
11
12const draft =
13 "> **Assistant and reasoning text still save intermediate full snapshots**, using the existing 50 ms batching. Compaction still preserves their `turn-item.updated` events. [Code](/Users/yashsingh/p/projects/t3code/apps/server/src/orchestration-v2/ProjectionMaintenance.ts:235)";
14
15let root: Root;
16let container: HTMLDivElement;
17
18beforeEach(() => {
19 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
20 container = document.createElement("div");
21 document.body.append(container);
22 root = createRoot(container);
23});
24
25afterEach(async () => {
26 await act(async () => root.unmount());
27 container.remove();
28 vi.unstubAllGlobals();
29});
30
31async function mountEditor(value: string, richTextEnabled = true) {
32 await act(async () =>
33 root.render(
34 <ComposerPromptEditor
35 editorRef={createRef<ComposerPromptEditorHandle>()}
36 value={value}
37 cursor={0}
38 richTextEnabled={richTextEnabled}
39 contextRecords={new Map()}
40 skills={[]}
41 disabled={false}
42 placeholder=""
43 onChange={() => {}}
44 onPaste={() => {}}
45 />,
46 ),
47 );
48 const element = container.querySelector<HTMLElement & { editor: Editor }>(".tiptap");
49 if (!element) throw new Error("Composer did not mount");
50 return element.editor;
51}
52
53function press(editor: Editor, key: string, options: KeyboardEventInit = {}) {
54 const event = new KeyboardEvent("keydown", { key, cancelable: true, ...options });
55 const handled = editor.view.props.handleKeyDown?.(editor.view, event);
56 expect(event.defaultPrevented).toBe(Boolean(handled));
57 return handled;
58}
59
60it("keeps Up before the opening bold marker in a quote, including while typing", async () => {
61 const editor = await mountEditor(draft);
62 const position = editor.state.selection.from;
63 await act(async () => {
64 editor.view.focus();
65 expect(press(editor, "ArrowUp")).toBe(true);
66 expect(editor.state.selection.from).toBe(position);
67 editor.view.updateState(editor.state);
68 const marker = editor.view.dom.querySelector(".composer-rich-marker");
69 const caret = window.getSelection()?.getRangeAt(0);
70 if (!marker || !caret) throw new Error("Caret or bold marker is missing");
71 const markerRange = document.createRange();
72 markerRange.selectNode(marker);
73 expect(caret.compareBoundaryPoints(Range.START_TO_START, markerRange)).toBeLessThanOrEqual(0);
74 // A repeated Up stays at the outside stop, ready for native navigation.
75 expect(press(editor, "ArrowUp")).toBe(false);
76 editor.commands.insertContent("x");
77 });
78 expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> x**"));
79});
80
81it("keeps Down after a closing marker, including while typing", async () => {
82 const editor = await mountEditor("> **bold**");
83 await act(async () => {
84 editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));
85 expect(press(editor, "ArrowDown")).toBe(true);
86 expect(press(editor, "ArrowDown")).toBe(false);
87 editor.commands.insertContent("x");
88 });
89 expect(serializeEditorDoc(editor.state.doc).value).toBe("> **bold**x");
90});
91
92it("leaves vertical navigation between paragraphs to the browser", async () => {
93 const editor = await mountEditor("**first**\n**second**");
94 await act(async () => {
95 const first = editor.state.doc.child(0);
96 editor.commands.setTextSelection(first.nodeSize + 1);
97 expect(press(editor, "ArrowUp")).toBe(false);
98 editor.commands.setTextSelection(first.nodeSize - 1);
99 expect(press(editor, "ArrowDown")).toBe(false);
100 });
101});
102
103it.each([
104 { shiftKey: true },
105 { altKey: true },
106 { metaKey: true },
107 { ctrlKey: true },
108 { isComposing: true },
109])("preserves modified Up behavior for %j", async (options) => {
110 const editor = await mountEditor(draft);
111 await act(async () => {
112 expect(press(editor, "ArrowUp", options)).toBe(false);
113 editor.commands.insertContent("x");
114 });
115 expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x"));
116});
117
118it("leaves literal Markdown navigation to the browser in plain mode", async () => {
119 const editor = await mountEditor(draft, false);
120 await act(async () => {
121 editor.commands.setTextSelection(5);
122 expect(press(editor, "ArrowUp")).toBe(false);
123 editor.commands.insertContent("x");
124 });
125 expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x"));
126});
apps/web/src/components/ComposerPromptEditorTiptap.tsx+16-4
......@@ -1238,7 +1238,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
12381238 }
12391239 }
12401240 if (
1241 (event.key === "ArrowLeft" || event.key === "ArrowRight") &&
1241 ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key) &&
12421242 !event.shiftKey &&
12431243 !event.altKey &&
12441244 !event.metaKey &&
......@@ -1247,10 +1247,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
12471247 view.state.selection.empty
12481248 ) {
12491249 const { $from } = view.state.selection;
1250 const direction = event.key === "ArrowLeft" ? -1 : 1;
1250 const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1;
1251 const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
1252 // At the document's ends, native Up/Down can cross a marker without
1253 // changing the document position. Record the stop so DOM selection
1254 // synchronization does not put the caret back inside the styling.
1255 const atDocumentEdge =
1256 $from.pos ===
1257 (direction === -1
1258 ? TextSelection.atStart(view.state.doc).from
1259 : TextSelection.atEnd(view.state.doc).to);
12511260 // Take the other stop of a styled edge before skipping a chip, so
12521261 // the plain stop between styled text and a chip stays reachable.
1253 const step = stepCaretAcrossStyledEdge(view.state, direction);
1262 const step =
1263 horizontal || atDocumentEdge
1264 ? stepCaretAcrossStyledEdge(view.state, direction)
1265 : null;
12541266 if (step) {
12551267 event.preventDefault();
12561268 event.stopPropagation();
......@@ -1258,7 +1270,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
12581270 return true;
12591271 }
12601272 const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter;
1261 if (adjacent?.type.name.startsWith("composer-")) {
1273 if (horizontal && adjacent?.type.name.startsWith("composer-")) {
12621274 event.preventDefault();
12631275 event.stopPropagation();
12641276 view.dispatch(