diff --git a/.babelrc b/.babelrc index d50502316..e5efe292f 100644 --- a/.babelrc +++ b/.babelrc @@ -15,6 +15,6 @@ "@babel/plugin-transform-runtime", "@babel/plugin-transform-block-scoping" ], - "compact": true, + "compact": false, "sourceMaps": "inline" } diff --git a/src/cm/editorReadOnly.ts b/src/cm/editorReadOnly.ts index c5e94acd4..927b7a8e7 100644 --- a/src/cm/editorReadOnly.ts +++ b/src/cm/editorReadOnly.ts @@ -63,6 +63,18 @@ const readOnlyFocusGuard = EditorView.domEventHandlers({ }, }); +const readOnlyCursor = [ + EditorView.editorAttributes.of({ class: "cm-read-only" }), + EditorView.theme({ + "&.cm-read-only > .cm-scroller > .cm-cursorLayer": { + animation: "none", + }, + "&.cm-read-only > .cm-scroller > .cm-cursorLayer .cm-cursor": { + display: "block", + }, + }), +]; + /** * Keep CodeMirror's document and DOM editability in sync. */ @@ -71,7 +83,12 @@ export function createEditorReadOnlyExtension(readOnly: boolean): Extension { EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly), ...(readOnly - ? [readOnlyFocusGuard, readOnlyInputGuard, readOnlyUserChangeFilter] + ? [ + readOnlyFocusGuard, + readOnlyInputGuard, + readOnlyUserChangeFilter, + readOnlyCursor, + ] : []), ]; } @@ -137,17 +154,17 @@ export function shouldCommitReadOnlyTap( return Math.hypot(end.x - start.x, end.y - start.y) <= maxDistance; } -/** Collapse an existing read-only selection without focusing or editing. */ -export function collapseReadOnlySelection( - view: EditorView, - pos: number, -): boolean { - if (!view.state.readOnly || view.state.selection.main.empty) return false; +/** Place the visual read-only cursor without focusing or editing. */ +export function placeReadOnlyCursor(view: EditorView, pos: number): boolean { + if (!view.state.readOnly) return false; const position = Math.max(0, Math.min(pos, view.state.doc.length)); - view.dispatch({ - selection: EditorSelection.cursor(position), - userEvent: "select.pointer", - }); + const selection = EditorSelection.single(position); + if (!view.state.selection.eq(selection)) { + view.dispatch({ + selection, + userEvent: "select.pointer", + }); + } blurEditorIfReadOnly(view, true); return true; } diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index 3e9809657..f99badae3 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -1,7 +1,7 @@ import { EditorSelection } from "@codemirror/state"; import { - collapseReadOnlySelection, focusEditorIfEditable, + placeReadOnlyCursor, resolveReadOnlyContextSelection, shouldCommitReadOnlyTap, } from "cm/editorReadOnly"; @@ -384,9 +384,7 @@ class TouchSelectionMenuController { #captureReadOnlyTap(event) { this.#readOnlyTapSession = null; - if (!this.#enabled || !this.#isReadOnly() || !this.#hasSelection()) { - return; - } + if (!this.#enabled || !this.#isReadOnly()) return; if (!(event.isTrusted && event.isPrimary)) return; if (typeof event.button === "number" && event.button !== 0) return; if (this.#canExtendSelection(event) || this.#canAddSelectionRange(event)) { @@ -440,7 +438,7 @@ class TouchSelectionMenuController { const pos = this.#safePosAtCoords(event.clientX, event.clientY); if (pos == null) return false; - if (!collapseReadOnlySelection(this.#view, pos)) return false; + if (!placeReadOnlyCursor(this.#view, pos)) return false; this.#menuRequested = false; this.#clearMenuShowTimer(); this.#hideMenu(true); diff --git a/src/handlers/quickTools.js b/src/handlers/quickTools.js index 42a8b9f38..00bcad9aa 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -34,6 +34,10 @@ import { removeActionStackEntries, shouldCaptureModifierInput, } from "./quickToolsState"; +import { + captureReadOnlyQuickToolsKey, + createReadOnlyQuickToolsCaptureSession, +} from "./readOnlyQuickToolsCapture"; export let quickToolUsed = false; @@ -44,6 +48,8 @@ let quickToolUsedTimeout = null; let activeSearchState = null; /** @type {MutationObserver | null} */ let searchCloseVisibilityObserver = null; +/** @type {import("./readOnlyQuickToolsCapture").ReadOnlyQuickToolsCaptureSession | null} */ +let readOnlyCaptureSession = null; const state = { shift: false, @@ -66,7 +72,16 @@ setQuickToolsModifierInputHandler(handleCodeMirrorQuickToolsTextInput); * @typedef {(value: boolean)=>void} QuickToolsEventListener */ +quickTools.$input.addEventListener("beforeinput", (event) => { + handleReadOnlyQuickToolsCaptureEvent(event); +}); + +quickTools.$input.addEventListener("compositionend", (event) => { + handleReadOnlyQuickToolsCaptureEvent(event); +}); + quickTools.$input.addEventListener("input", (e) => { + if (handleReadOnlyQuickToolsCaptureEvent(e)) return; const key = e.target.value.toUpperCase(); quickTools.$input.value = ""; if (!key || key.length > 1) return; @@ -98,6 +113,7 @@ quickTools.$input.addEventListener("input", (e) => { }); quickTools.$input.addEventListener("keydown", (e) => { + if (handleReadOnlyQuickToolsCaptureEvent(e)) return; const { keyCode, key, which } = e; const keyCombination = getKeys({ keyCode, key, which }); @@ -245,11 +261,15 @@ export const key = { export function clearQuickToolsModifierState({ restoreFocus = false } = {}) { const changed = clearModifierState(state, events); + if (!restoreFocus || !readOnlyCaptureSession?.consumed) { + clearReadOnlyCaptureSession(); + } if (restoreFocus) restoreQuickToolsTargetFocus(); return changed; } export function cancelQuickToolsModifierInput() { + clearReadOnlyCaptureSession(); const changed = clearQuickToolsModifierState(); quickTools.$input.value = ""; quickTools.$input.blur(); @@ -284,14 +304,21 @@ export default function actions(action, value) { if (shouldCapture) { $input.value = ""; if (codeMirrorView?.state.readOnly) { + readOnlyCaptureSession = createReadOnlyQuickToolsCaptureSession( + codeMirrorView, + getQuickToolsModifierSnapshot(), + ); focusQuickToolsModifierInput(codeMirrorView, $input); } else { + clearReadOnlyCaptureSession(); $input.focus(); } } else { + clearReadOnlyCaptureSession(); if (codeMirrorView) focusEditorIfEditable(codeMirrorView); } } else { + clearReadOnlyCaptureSession(); restoreQuickToolsTargetFocus(); } @@ -440,6 +467,67 @@ function getCodeMirrorInputView(target) { : null; } +function handleReadOnlyQuickToolsCaptureEvent(event) { + const session = readOnlyCaptureSession; + if (!session) return false; + + const view = session.target; + if ( + !view?.state?.readOnly || + !view.contentDOM?.isConnected || + !view.dom?.isConnected + ) { + cancelQuickToolsModifierInput(); + preventCaptureInput(event); + return true; + } + + const result = captureReadOnlyQuickToolsKey(session, { + type: event.type, + key: event.key, + data: event.data, + value: quickTools.$input.value, + inputType: event.inputType, + isComposing: event.isComposing, + }); + readOnlyCaptureSession = result.session; + + if (result.outcome.kind === "pass") return false; + if (result.outcome.kind === "pending") { + if (!event.isComposing) { + quickTools.$input.value = ""; + if (event.type === "beforeinput") preventCaptureInput(event); + } + return true; + } + + quickTools.$input.value = ""; + preventCaptureInput(event); + if (result.outcome.kind === "duplicate") return true; + + const key = result.outcome.key.toUpperCase(); + const keyCombination = { key, ...session.modifiers }; + runCodeMirrorQuickToolsTextKey(view, key, keyCombination); + return true; +} + +function preventCaptureInput(event) { + if (event.cancelable) event.preventDefault(); +} + +function getQuickToolsModifierSnapshot() { + return { + shiftKey: state.shift, + altKey: state.alt, + ctrlKey: state.ctrl, + metaKey: state.meta, + }; +} + +function clearReadOnlyCaptureSession() { + readOnlyCaptureSession = null; +} + function runCodeMirrorQuickToolKey(keyCode, keyCombination) { const view = getCodeMirrorInputView(input); return view ? runQuickToolKey(view, keyCode, keyCombination) : false; diff --git a/src/handlers/readOnlyQuickToolsCapture.ts b/src/handlers/readOnlyQuickToolsCapture.ts new file mode 100644 index 000000000..3b7c96b72 --- /dev/null +++ b/src/handlers/readOnlyQuickToolsCapture.ts @@ -0,0 +1,130 @@ +interface ReadOnlyCaptureTarget { + state?: { + readOnly?: boolean; + }; +} + +export interface QuickToolsModifierSnapshot { + shiftKey: boolean; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; +} + +export interface ReadOnlyQuickToolsCaptureSession< + Target extends ReadOnlyCaptureTarget = ReadOnlyCaptureTarget, +> { + target: Target; + modifiers: QuickToolsModifierSnapshot; + consumed: boolean; +} + +export interface ReadOnlyQuickToolsCaptureEvent { + type: "keydown" | "beforeinput" | "input" | "compositionend"; + key?: string | null; + data?: string | null; + value?: string | null; + inputType?: string | null; + isComposing?: boolean; +} + +export type ReadOnlyQuickToolsCaptureOutcome = + | { kind: "key"; key: string } + | { kind: "pending" } + | { kind: "duplicate" } + | { kind: "pass" }; + +export interface ReadOnlyQuickToolsCaptureResult< + Target extends ReadOnlyCaptureTarget = ReadOnlyCaptureTarget, +> { + session: ReadOnlyQuickToolsCaptureSession; + outcome: ReadOnlyQuickToolsCaptureOutcome; +} + +/** + * Create a one-shot soft-keyboard capture only for a read-only CodeMirror + * target. Editable editors intentionally stay on the established QuickTools + * input path. + */ +export function createReadOnlyQuickToolsCaptureSession< + Target extends ReadOnlyCaptureTarget, +>( + target: Target | null | undefined, + modifiers: Partial, +): ReadOnlyQuickToolsCaptureSession | null { + if (!target?.state?.readOnly) return null; + return { + target, + modifiers: { + shiftKey: !!modifiers.shiftKey, + altKey: !!modifiers.altKey, + ctrlKey: !!modifiers.ctrlKey, + metaKey: !!modifiers.metaKey, + }, + consumed: false, + }; +} + +/** + * Normalize the event variants emitted by Android keyboards. A session is + * consumed by the first unambiguous character and then absorbs duplicate DOM + * events generated for that same keystroke. + */ +export function captureReadOnlyQuickToolsKey< + Target extends ReadOnlyCaptureTarget, +>( + session: ReadOnlyQuickToolsCaptureSession, + event: ReadOnlyQuickToolsCaptureEvent, +): ReadOnlyQuickToolsCaptureResult { + if (session.consumed) { + return { session, outcome: { kind: "duplicate" } }; + } + + if (event.type === "keydown") { + if (isCompositionKey(event.key)) { + return { session, outcome: { kind: "pending" } }; + } + // Gboard can mark a real printable key as composing. Waiting for + // compositionend here leaves the shortcut armed until the IME is dismissed. + const key = getSingleCharacter(event.key); + if (!key) return { session, outcome: { kind: "pass" } }; + return consumeSession(session, key); + } + + if (event.inputType?.startsWith("delete")) { + return { session, outcome: { kind: "pending" } }; + } + + if (event.data !== null && event.data !== undefined) { + // A single composition update is already an unambiguous shortcut key. + const key = getSingleCharacter(event.data); + return key + ? consumeSession(session, key) + : { session, outcome: { kind: "pending" } }; + } + + const key = getSingleCharacter(event.value); + return key + ? consumeSession(session, key) + : { session, outcome: { kind: "pending" } }; +} + +function consumeSession( + session: ReadOnlyQuickToolsCaptureSession, + key: string, +): ReadOnlyQuickToolsCaptureResult { + return { + session: { ...session, consumed: true }, + outcome: { kind: "key", key }, + }; +} + +function getSingleCharacter(value: string | null | undefined): string | null { + if (!value) return null; + const characters = Array.from(value); + return characters.length === 1 ? characters[0] : null; +} + +function isCompositionKey(key: string | null | undefined): boolean { + return key === "Dead" || key === "Process" || key === "Unidentified"; +} diff --git a/tests/unit/editorReadOnly.test.ts b/tests/unit/editorReadOnly.test.ts index 64f9524aa..0ae77e840 100644 --- a/tests/unit/editorReadOnly.test.ts +++ b/tests/unit/editorReadOnly.test.ts @@ -1,12 +1,12 @@ // @vitest-environment happy-dom import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import { drawSelection, EditorView } from "@codemirror/view"; import { - collapseReadOnlySelection, createEditorReadOnlyExtension, focusEditorIfEditable, isReadOnlyUserChange, + placeReadOnlyCursor, reconfigureEditorReadOnly, resolveReadOnlyContextSelection, shouldCommitReadOnlyTap, @@ -20,11 +20,14 @@ afterEach(() => { document.body.replaceChildren(); }); -function createEditor(readOnly = false) { +function createEditor(readOnly = false, doc = "read only content") { const compartment = new Compartment(); const state = EditorState.create({ - doc: "read only content", - extensions: [compartment.of(createEditorReadOnlyExtension(readOnly))], + doc, + extensions: [ + drawSelection(), + compartment.of(createEditorReadOnlyExtension(readOnly)), + ], }); const view = new EditorView({ state, parent: document.body }); views.push(view); @@ -37,6 +40,7 @@ function expectEditable(view: EditorView) { expect(view.contentDOM.getAttribute("contenteditable")).toBe("true"); expect(view.contentDOM.hasAttribute("aria-readonly")).toBe(false); expect(view.contentDOM.hasAttribute("tabindex")).toBe(false); + expect(view.dom.classList.contains("cm-read-only")).toBe(false); } function expectReadOnly(view: EditorView) { @@ -45,6 +49,7 @@ function expectReadOnly(view: EditorView) { expect(view.contentDOM.getAttribute("contenteditable")).toBe("false"); expect(view.contentDOM.getAttribute("aria-readonly")).toBe("true"); expect(view.contentDOM.hasAttribute("tabindex")).toBe(false); + expect(view.dom.classList.contains("cm-read-only")).toBe(true); } describe("editor read-only configuration", () => { @@ -67,6 +72,22 @@ describe("editor read-only configuration", () => { expect(view.state.doc.toString()).toBe(originalDocument); }); + it("reveals CodeMirror's static cursor only in read-only mode", () => { + const { compartment, view } = createEditor(true); + const cursorLayer = view.dom.querySelector(".cm-cursorLayer"); + expect(cursorLayer).not.toBeNull(); + const cursor = document.createElement("span"); + cursor.className = "cm-cursor cm-cursor-primary"; + cursorLayer!.append(cursor); + + expect(getComputedStyle(cursor).display).toBe("block"); + expect(view.hasFocus).toBe(false); + + reconfigureEditorReadOnly(view, compartment, false); + expect(view.dom.classList.contains("cm-read-only")).toBe(false); + expect(getComputedStyle(cursor).display).toBe("none"); + }); + it("blocks user document changes but permits internal synchronization", () => { const { view } = createEditor(true); view.dispatch({ selection: EditorSelection.range(0, 4) }); @@ -125,17 +146,33 @@ describe("editor read-only configuration", () => { expect(editable.hasFocus).toBe(true); }); - it("collapses a read-only selection without editing or focusing", () => { + it("places a read-only cursor without editing or focusing", () => { const { view } = createEditor(true); view.dispatch({ selection: EditorSelection.range(0, 4) }); const originalDocument = view.state.doc.toString(); - expect(collapseReadOnlySelection(view, 7)).toBe(true); + expect(placeReadOnlyCursor(view, 7)).toBe(true); expect(view.state.selection.main.empty).toBe(true); expect(view.state.selection.main.head).toBe(7); expect(view.state.doc.toString()).toBe(originalDocument); expect(view.hasFocus).toBe(false); - expect(collapseReadOnlySelection(view, 2)).toBe(false); + + expect(placeReadOnlyCursor(view, 2)).toBe(true); + expect(view.state.selection.main.head).toBe(2); + expect(placeReadOnlyCursor(view, 2)).toBe(true); + expect(view.state.doc.toString()).toBe(originalDocument); + }); + + it("clamps cursor placement in an empty document and declines editable views", () => { + const readOnly = createEditor(true, "").view; + const editable = createEditor(false).view; + + expect(placeReadOnlyCursor(readOnly, 10)).toBe(true); + expect(readOnly.state.selection.main.head).toBe(0); + expect(readOnly.hasFocus).toBe(false); + + expect(placeReadOnlyCursor(editable, 5)).toBe(false); + expect(editable.state.selection.main.head).toBe(0); }); it("accepts only uncancelled short primary taps", () => { diff --git a/tests/unit/quickToolsReadOnly.test.ts b/tests/unit/quickToolsReadOnly.test.ts index 472c4b2a8..bfc01870d 100644 --- a/tests/unit/quickToolsReadOnly.test.ts +++ b/tests/unit/quickToolsReadOnly.test.ts @@ -11,6 +11,10 @@ import quickToolsModifierInput, { setQuickToolsModifierInputHandler, } from "cm/quickToolsModifierInput"; import { runQuickToolKey } from "cm/quickToolsNavigation"; +import { + captureReadOnlyQuickToolsKey, + createReadOnlyQuickToolsCaptureSession, +} from "handlers/readOnlyQuickToolsCapture"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("utils/keyboardEvent", () => ({ @@ -50,6 +54,58 @@ describe("read-only QuickTools interaction", () => { expect(view.hasFocus).toBe(false); }); + it("executes a captured Ctrl+A once across duplicate Android events", () => { + const view = createEditor(true, [ + keymap.of([{ key: "Ctrl-a", run: selectAll }]), + ]); + const captureInput = document.createElement("textarea"); + document.body.append(captureInput); + const originalDocument = view.state.doc.toString(); + let session = createReadOnlyQuickToolsCaptureSession(view, { + ctrlKey: true, + }); + let commandRuns = 0; + + expect(session).not.toBeNull(); + expect(focusQuickToolsModifierInput(view, captureInput)).toBe(true); + if (!session) return; + + for (const event of [ + { + type: "keydown" as const, + key: "Process", + isComposing: true, + }, + { + type: "beforeinput" as const, + data: "a", + inputType: "insertCompositionText", + isComposing: true, + }, + { + type: "input" as const, + data: "a", + value: "a", + isComposing: true, + }, + { type: "compositionend" as const, data: "a" }, + ]) { + const result = captureReadOnlyQuickToolsKey(session, event); + session = result.session; + if (result.outcome.kind !== "key") continue; + commandRuns += 1; + runQuickToolKey(view, 65, session.modifiers); + finishQuickToolsModifierInput(view, captureInput); + } + + expect(commandRuns).toBe(1); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(view.state.doc.length); + expect(view.state.doc.toString()).toBe(originalDocument); + expect(document.activeElement).not.toBe(captureInput); + expect(view.hasFocus).toBe(false); + }); + it("allows selection navigation but blocks deletion", () => { const view = createEditor(true); view.dispatch({ selection: EditorSelection.cursor(0) }); diff --git a/tests/unit/readOnlyQuickToolsCapture.test.ts b/tests/unit/readOnlyQuickToolsCapture.test.ts new file mode 100644 index 000000000..c11395c90 --- /dev/null +++ b/tests/unit/readOnlyQuickToolsCapture.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import { + captureReadOnlyQuickToolsKey, + createReadOnlyQuickToolsCaptureSession, +} from "handlers/readOnlyQuickToolsCapture"; + +const modifiers = { + shiftKey: false, + altKey: false, + ctrlKey: true, + metaKey: false, +}; + +function readOnlyTarget() { + return { state: { readOnly: true } }; +} + +describe("read-only QuickTools capture", () => { + it("declines editable targets", () => { + expect( + createReadOnlyQuickToolsCaptureSession( + { state: { readOnly: false } }, + modifiers, + ), + ).toBeNull(); + }); + + it.each([ + ["keydown", { key: "a" }], + ["beforeinput", { data: "a", inputType: "insertText" }], + ["input", { data: "a", inputType: "insertText" }], + ["compositionend", { data: "a" }], + ])("captures one key from %s", (type, event) => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const result = captureReadOnlyQuickToolsKey(session, { + type: type as "keydown" | "beforeinput" | "input" | "compositionend", + ...event, + }); + expect(result.outcome).toEqual({ kind: "key", key: "a" }); + expect(result.session.consumed).toBe(true); + } + }); + + it("prefers event data over a stale textarea value", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const result = captureReadOnlyQuickToolsKey(session, { + type: "input", + data: "a", + value: "stale-a", + }); + expect(result.outcome).toEqual({ kind: "key", key: "a" }); + } + }); + + it("uses the textarea only when event data is unavailable", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const result = captureReadOnlyQuickToolsKey(session, { + type: "input", + data: null, + value: "a", + }); + expect(result.outcome).toEqual({ kind: "key", key: "a" }); + } + }); + + it.each([ + { type: "input", data: "", value: "a" }, + { type: "input", data: "aa", value: "a" }, + { type: "input", data: null, value: "aa" }, + { + type: "beforeinput", + data: null, + value: "a", + inputType: "deleteContentBackward", + }, + ])("keeps ambiguous or deletion-only input armed", (event) => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const result = captureReadOnlyQuickToolsKey(session, { + ...event, + type: event.type as "beforeinput" | "input", + }); + expect(result.outcome).toEqual({ kind: "pending" }); + expect(result.session.consumed).toBe(false); + } + }); + + it("accepts a valid key after an ambiguous event", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const ambiguous = captureReadOnlyQuickToolsKey(session, { + type: "input", + data: null, + value: "aa", + }); + expect(ambiguous.outcome).toEqual({ kind: "pending" }); + + const valid = captureReadOnlyQuickToolsKey(ambiguous.session, { + type: "input", + data: "a", + value: "a", + }); + expect(valid.outcome).toEqual({ kind: "key", key: "a" }); + expect(valid.session.consumed).toBe(true); + } + }); + + it("captures the first usable character from an Android composition", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const processKey = captureReadOnlyQuickToolsKey(session, { + type: "keydown", + key: "Process", + isComposing: true, + }); + expect(processKey.outcome).toEqual({ kind: "pending" }); + + const beforeInput = captureReadOnlyQuickToolsKey(processKey.session, { + type: "beforeinput", + data: "a", + inputType: "insertCompositionText", + isComposing: true, + }); + expect(beforeInput.outcome).toEqual({ kind: "key", key: "a" }); + + const input = captureReadOnlyQuickToolsKey(beforeInput.session, { + type: "input", + data: "a", + inputType: "insertCompositionText", + isComposing: true, + }); + expect(input.outcome).toEqual({ kind: "duplicate" }); + + const completed = captureReadOnlyQuickToolsKey(input.session, { + type: "compositionend", + data: "a", + }); + expect(completed.outcome).toEqual({ kind: "duplicate" }); + } + }); + + it("accepts a printable composing keydown immediately", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const result = captureReadOnlyQuickToolsKey(session, { + type: "keydown", + key: "a", + isComposing: true, + }); + expect(result.outcome).toEqual({ kind: "key", key: "a" }); + } + }); + + it("uses a single textarea character during composition when data is null", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const result = captureReadOnlyQuickToolsKey(session, { + type: "input", + data: null, + value: "a", + isComposing: true, + }); + expect(result.outcome).toEqual({ kind: "key", key: "a" }); + } + }); + + it("passes non-printable keydown events to existing navigation handling", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const result = captureReadOnlyQuickToolsKey(session, { + type: "keydown", + key: "ArrowRight", + }); + expect(result.outcome).toEqual({ kind: "pass" }); + expect(result.session.consumed).toBe(false); + } + }); + + it("absorbs duplicate events after the first captured key", () => { + const session = createReadOnlyQuickToolsCaptureSession( + readOnlyTarget(), + modifiers, + ); + expect(session).not.toBeNull(); + + if (session) { + const first = captureReadOnlyQuickToolsKey(session, { + type: "beforeinput", + data: "a", + }); + const duplicate = captureReadOnlyQuickToolsKey(first.session, { + type: "input", + data: "a", + value: "a", + }); + expect(duplicate.outcome).toEqual({ kind: "duplicate" }); + expect(duplicate.session.consumed).toBe(true); + } + }); +});