Browse / github.com/storytold/artcraft / patch
fix(promptbox): preserve arrow direction when exiting mention chips
fix(promptbox): preserve arrow direction when exiting mention chips When native arrow movement places the caret inside a non-editable mention chip, the selection-change fallback always moves it after the chip. Entering from that same trailing edge can therefore trap backward navigation. Remember the origin of a bare horizontal arrow and eject the caret to the opposite edge, leaving native grapheme and bidi movement to the browser. Consume the origin on selection changes and clear it on other keys, pointer input, and blur so unrelated caret placement retains the existing fallback. Related #1337. This fixes the mention-chip navigation fallback. Native Mac veri
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- channelramble (imported from pull request #2236, open)
- Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373- Size
- +251 โ5 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
fed1a7864a3c6a095eb82e77
How it went
No other operator has reported using it yet.
Diff
raw ยท frontend/libs/components/promptbox/src/lib/MentionTextarea.navigation.spec.tsx frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx netlify.toml
new file mode 100644@@ -0,0 +1,196 @@+import { act, createElement } from "react";+import { createRoot } from "react-dom/client";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import { MentionTextarea } from "./MentionTextarea";++// Keep the real editable and selection handlers; these UI subtrees are unused.+vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null }));+vi.mock("./MentionChipMenu", () => ({ MentionChipMenu: () => null }));+vi.mock("./deck/DeckCard", () => ({ DeckPreviewModal: () => null }));+vi.mock("./promptStore", () => ({+ useEnterToGenerateStore: (select: (s: { enabled: boolean }) => unknown) =>+ select({ enabled: false }),+}));++const PREFIX = "๐e\u0301";+const SUFFIX = "๐ฉโ๐";+const VALUE = `${PREFIX}@Image1${SUFFIX}`;+let container: HTMLDivElement;+let root: ReturnType<typeof createRoot>;+let editor: HTMLDivElement;+let chip: HTMLElement;+let onChange: ReturnType<typeof vi.fn>;+let onKeyDown: ReturnType<typeof vi.fn>;++beforeEach(() => {+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);+ container = document.createElement("div");+ document.body.appendChild(container);+ root = createRoot(container);+ onChange = vi.fn();+ onKeyDown = vi.fn();+ act(() => root.render(createElement(MentionTextarea, {+ value: VALUE,+ mentionItems: [{ label: "@Image1", type: "image" }],+ colorMap: {},+ onChange,+ onKeyDown,+ })));+ editor = container.querySelector("[contenteditable=true]")!;+ chip = editor.querySelector("[data-mention]")!;+ editor.focus();+});++afterEach(() => {+ act(() => root.unmount());+ window.getSelection()?.removeAllRanges();+ container.remove();+ vi.unstubAllGlobals();+});++describe("atomic mention arrow navigation", () => {+ it("moves left past a chip instead of being ejected back to its trailing edge", () => {+ setCaret(chip.nextSibling!, 0);+ const html = editor.innerHTML;+ const event = pressArrow("ArrowLeft");++ // jsdom does not implement native arrow movement. Reproduce the browser+ // landing inside contenteditable=false, then run the real caret-ejection+ // listener. The editor must leave ordinary arrow movement to the browser.+ if (!event.defaultPrevented) {+ setCaret(chip.lastChild!.firstChild!, 0);+ }+ act(() => document.dispatchEvent(new Event("selectionchange")));++ expect(selectedTextBeforeCaret()).toBe(PREFIX);+ expect(event.defaultPrevented).toBe(false);+ expect(editor.innerHTML).toBe(html);+ expect(onChange).not.toHaveBeenCalled();+ });++ it("moves right past a chip without entering its non-editable contents", () => {+ setCaret(chip.previousSibling!, PREFIX.length);+ const html = editor.innerHTML;+ const event = pressArrow("ArrowRight");+ setCaret(chip.lastChild!.firstChild!, 0);+ act(() => document.dispatchEvent(new Event("selectionchange")));++ expect(selectedTextBeforeCaret()).toBe(`${PREFIX}@Image1`);+ expect(event.defaultPrevented).toBe(false);+ expect(editor.innerHTML).toBe(html);+ expect(onChange).not.toHaveBeenCalled();+ });++ it("uses the origin edge rather than assuming a left-to-right text direction", () => {+ setCaret(chip.nextSibling!, 0);+ // In a bidi run, ArrowRight can enter a chip from its DOM trailing edge.+ const event = pressArrow("ArrowRight");+ setCaret(chip.lastChild!.firstChild!, 0);+ act(() => document.dispatchEvent(new Event("selectionchange")));++ expect(selectedTextBeforeCaret()).toBe(PREFIX);+ expect(event.defaultPrevented).toBe(false);+ });++ it.each(["selectionchange", "pointerdown", "mousedown", "blur", "keydown"])("clears the arrow origin after %s", (eventType) => {+ setCaret(chip.nextSibling!, 0);+ pressArrow("ArrowLeft");+ if (eventType === "selectionchange") {+ setCaret(chip.previousSibling!, 0);+ act(() => document.dispatchEvent(new Event("selectionchange")));+ } else if (eventType === "blur") {+ act(() => editor.blur());+ editor.focus();+ } else if (eventType === "keydown") {+ pressArrow("a");+ } else {+ act(() => editor.dispatchEvent(new Event(eventType, { bubbles: true })));+ }+ setCaret(chip.lastChild!.firstChild!, 0);+ act(() => document.dispatchEvent(new Event("selectionchange")));++ expect(selectedTextBeforeCaret()).toBe(`${PREFIX}@Image1`);+ });++ it.each(["ArrowLeft", "ArrowRight"])("leaves %s within Unicode text to native grapheme navigation", (key) => {+ setCaret(chip.nextSibling!, SUFFIX.length);+ const before = window.getSelection()!.getRangeAt(0).cloneRange();+ const event = pressArrow(key);++ expect(event.defaultPrevented).toBe(false);+ expect(window.getSelection()!.anchorNode).toBe(before.startContainer);+ expect(window.getSelection()!.anchorOffset).toBe(before.startOffset);+ expect(editor.textContent).toBe(VALUE);+ expect(onKeyDown).toHaveBeenCalledOnce();+ expect(onChange).not.toHaveBeenCalled();+ });++ it.each(["shiftKey", "altKey", "ctrlKey", "metaKey", "isComposing"])("preserves native modified movement (%s)", (flag) => {+ setCaret(chip.nextSibling!, 0);+ const event = pressArrow("ArrowLeft", { [flag]: true });++ expect(event.defaultPrevented).toBe(false);+ expect(window.getSelection()!.anchorNode).toBe(chip.nextSibling);+ expect(window.getSelection()!.anchorOffset).toBe(0);+ });++ it.each(["compositionstart", "keyCode"])("does not track arrows during IME (%s)", (mode) => {+ setCaret(chip.nextSibling!, 0);+ if (mode === "compositionstart") {+ act(() => editor.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));+ }+ const event = pressArrow("ArrowLeft", mode === "keyCode" ? { keyCode: 229 } : {});+ setCaret(chip.lastChild!.firstChild!, 0);+ act(() => document.dispatchEvent(new Event("selectionchange")));++ expect(selectedTextBeforeCaret()).toBe(`${PREFIX}@Image1`);+ expect(event.defaultPrevented).toBe(false);+ });++ it("preserves non-collapsed selection for native arrow collapse", () => {+ const range = document.createRange();+ range.selectNode(chip);+ const selection = window.getSelection()!;+ selection.removeAllRanges();+ selection.addRange(range);+ const event = pressArrow("ArrowLeft");++ expect(event.defaultPrevented).toBe(false);+ expect(selection.isCollapsed).toBe(false);+ expect(selection.toString()).toBe("@Image1");+ });++ it("keeps the fallback for an unexpected caret inside a chip", () => {+ setCaret(chip.lastChild!.firstChild!, 0);+ act(() => document.dispatchEvent(new Event("selectionchange")));++ expect(window.getSelection()!.anchorNode).toBe(editor);+ expect(window.getSelection()!.anchorOffset).toBe(2);+ expect(onChange).not.toHaveBeenCalled();+ });+});++function setCaret(node: Node, offset: number) {+ const range = document.createRange();+ range.setStart(node, offset);+ range.collapse(true);+ const selection = window.getSelection()!;+ selection.removeAllRanges();+ selection.addRange(range);+}++function pressArrow(key: string, flags: KeyboardEventInit = {}) {+ const event = new KeyboardEvent("keydown", {+ key, bubbles: true, cancelable: true, ...flags,+ });+ act(() => editor.dispatchEvent(event));+ return event;+}++function selectedTextBeforeCaret() {+ const selection = window.getSelection()!;+ const range = document.createRange();+ range.setStart(editor, 0);+ range.setEnd(selection.anchorNode!, selection.anchorOffset);+ return range.toString();+}@@ -639,6 +639,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>( const isInternalUpdate = useRef(false); const isComposing = useRef(false); const pendingCaret = useRef<number | null>(null);+ const arrowNavigationOrigin = useRef<number | null>(null); // One-shot guard: a programmatic edit (chip replace) leaves the caret // right after a mention label, which detectMention would read as a fresh // "@..." trigger and reopen the dropdown over the already-confirmed chip.@@ -887,12 +888,16 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>( // Last line of defense for the frozen-input bug: browsers can still drop // the caret INSIDE a contenteditable=false chip (Firefox arrow keys,- // drag-selection collapse, IME) โ typing is then silently ignored. Eject- // the caret to just after the chip. Guarded by activeElement so a stale- // selection can't steal focus back from an open popover (Chrome focuses- // a contentEditable on addRange into it).+ // drag-selection collapse, IME) โ typing is then silently ignored. When+ // an arrow enters a chip, exit on the opposite edge from its origin;+ // always exiting after it traps leftward navigation at the same chip.+ // Other caret placements still fall back to after the chip. Guarded by+ // activeElement so a stale selection can't steal focus back from an+ // open popover: Chrome focuses a contentEditable on addRange into it. useEffect(() => { const handleSelectionChange = () => {+ const origin = arrowNavigationOrigin.current;+ arrowNavigationOrigin.current = null; const el = editorRef.current; if (!el || document.activeElement !== el) return; const sel = window.getSelection();@@ -901,7 +906,14 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>( if (!chip) return; try { const range = document.createRange();- range.setStartAfter(chip);+ if (+ origin !== null &&+ origin >= getNodeStartOffset(el, chip) + nodeLength(chip)+ ) {+ range.setStartBefore(chip);+ } else {+ range.setStartAfter(chip);+ } range.collapse(true); sel.removeAllRanges(); sel.addRange(range);@@ -1323,6 +1335,32 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>( const handleKeyDown = useCallback( (e: React.KeyboardEvent<HTMLDivElement>) => {+ // Preserve native grapheme and bidi navigation: only remember where+ // a bare horizontal arrow started, for the chip-ejection fallback.+ arrowNavigationOrigin.current = null;+ if (+ (e.key === "ArrowLeft" || e.key === "ArrowRight") &&+ !e.shiftKey &&+ !e.altKey &&+ !e.ctrlKey &&+ !e.metaKey &&+ !isComposing.current &&+ !e.nativeEvent.isComposing &&+ e.nativeEvent.keyCode !== 229+ ) {+ const el = editorRef.current;+ const sel = window.getSelection();+ if (+ el &&+ sel?.isCollapsed &&+ sel.anchorNode &&+ el.contains(sel.anchorNode) &&+ !chipContaining(el, sel.anchorNode)+ ) {+ arrowNavigationOrigin.current = getCaretOffset(el);+ }+ }+ if (mentionState.isOpen && filteredItems.length > 0) { if (e.key === "ArrowDown") { e.preventDefault();@@ -1442,6 +1480,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>( const handleMouseDown = useCallback( (e: React.MouseEvent<HTMLDivElement>) => {+ arrowNavigationOrigin.current = null; // Clicking a chip must not move the caret into/around it. const target = e.target as HTMLElement; if (target.closest?.("[data-mention]")) {@@ -1590,6 +1629,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>( onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown}+ onPointerDown={() => {+ arrowNavigationOrigin.current = null;+ }} onMouseDown={handleMouseDown} onClick={handleClick} onPaste={handlePaste}@@ -1597,6 +1639,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>( onCut={handleCut} onFocus={onFocus} onBlur={(e) => {+ arrowNavigationOrigin.current = null; // Mouse clicks on dropdown items preventDefault pointerdown, so // they never land here โ but touch taps can move focus into the // panel (Android focuses the row button) before its click fires.new file mode 100644@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+ ignore = "exit 0"