From 7bdfed98d733d1bcda99f43d43b24cd6913f2fce Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 14 Oct 2026 18:09:01 -0500 Subject: [PATCH] fix(web): keep vertical cursor stops outside styled markers (#19651) --- ...oserPromptEditorTiptap.navigation.test.tsx | 126 ++++++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 20 ++- 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53bc7104cbc3aac5ef80252c150be29b9e72ac10 --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom + +import type { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; +import { act, createRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { serializeEditorDoc } from "~/composer-rich-text-doc"; +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; + +const draft = + "> **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)"; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function mountEditor(value: string, richTextEnabled = true) { + await act(async () => + root.render( + ()} + value={value} + cursor={0} + richTextEnabled={richTextEnabled} + contextRecords={new Map()} + skills={[]} + disabled={false} + placeholder="" + onChange={() => {}} + onPaste={() => {}} + />, + ), + ); + const element = container.querySelector(".tiptap"); + if (!element) throw new Error("Composer did not mount"); + return element.editor; +} + +function press(editor: Editor, key: string, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { key, cancelable: true, ...options }); + const handled = editor.view.props.handleKeyDown?.(editor.view, event); + expect(event.defaultPrevented).toBe(Boolean(handled)); + return handled; +} + +it("keeps Up before the opening bold marker in a quote, including while typing", async () => { + const editor = await mountEditor(draft); + const position = editor.state.selection.from; + await act(async () => { + editor.view.focus(); + expect(press(editor, "ArrowUp")).toBe(true); + expect(editor.state.selection.from).toBe(position); + editor.view.updateState(editor.state); + const marker = editor.view.dom.querySelector(".composer-rich-marker"); + const caret = window.getSelection()?.getRangeAt(0); + if (!marker || !caret) throw new Error("Caret or bold marker is missing"); + const markerRange = document.createRange(); + markerRange.selectNode(marker); + expect(caret.compareBoundaryPoints(Range.START_TO_START, markerRange)).toBeLessThanOrEqual(0); + // A repeated Up stays at the outside stop, ready for native navigation. + expect(press(editor, "ArrowUp")).toBe(false); + editor.commands.insertContent("x"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> x**")); +}); + +it("keeps Down after a closing marker, including while typing", async () => { + const editor = await mountEditor("> **bold**"); + await act(async () => { + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + expect(press(editor, "ArrowDown")).toBe(true); + expect(press(editor, "ArrowDown")).toBe(false); + editor.commands.insertContent("x"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("> **bold**x"); +}); + +it("leaves vertical navigation between paragraphs to the browser", async () => { + const editor = await mountEditor("**first**\n**second**"); + await act(async () => { + const first = editor.state.doc.child(0); + editor.commands.setTextSelection(first.nodeSize + 1); + expect(press(editor, "ArrowUp")).toBe(false); + editor.commands.setTextSelection(first.nodeSize - 1); + expect(press(editor, "ArrowDown")).toBe(false); + }); +}); + +it.each([ + { shiftKey: true }, + { altKey: true }, + { metaKey: true }, + { ctrlKey: true }, + { isComposing: true }, +])("preserves modified Up behavior for %j", async (options) => { + const editor = await mountEditor(draft); + await act(async () => { + expect(press(editor, "ArrowUp", options)).toBe(false); + editor.commands.insertContent("x"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x")); +}); + +it("leaves literal Markdown navigation to the browser in plain mode", async () => { + const editor = await mountEditor(draft, false); + await act(async () => { + editor.commands.setTextSelection(5); + expect(press(editor, "ArrowUp")).toBe(false); + editor.commands.insertContent("x"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x")); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4729c0fa2e3e4c6066af925640adbd86b85ca6d1..a9accaf6e013ce425f1fafee1dac211163e54ea0 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1238,7 +1238,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } } if ( - (event.key === "ArrowLeft" || event.key === "ArrowRight") && + ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key) && !event.shiftKey && !event.altKey && !event.metaKey && @@ -1247,10 +1247,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { view.state.selection.empty ) { const { $from } = view.state.selection; - const direction = event.key === "ArrowLeft" ? -1 : 1; + const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1; + const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"; + // At the document's ends, native Up/Down can cross a marker without + // changing the document position. Record the stop so DOM selection + // synchronization does not put the caret back inside the styling. + const atDocumentEdge = + $from.pos === + (direction === -1 + ? TextSelection.atStart(view.state.doc).from + : TextSelection.atEnd(view.state.doc).to); // 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); + const step = + horizontal || atDocumentEdge + ? stepCaretAcrossStyledEdge(view.state, direction) + : null; if (step) { event.preventDefault(); event.stopPropagation(); @@ -1258,7 +1270,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; } const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; - if (adjacent?.type.name.startsWith("composer-")) { + if (horizontal && adjacent?.type.name.startsWith("composer-")) { event.preventDefault(); event.stopPropagation(); view.dispatch( -- 2.54.0