From c9f0688eec417b7aa481f2e46b1c6cf70ae41328 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Fri, 14 Aug 2026 11:38:41 +0530 Subject: [PATCH] fix: quicktools ctrl+c not working properly --- src/cm/quickToolsModifierInput.ts | 36 +++- src/handlers/quickTools.js | 50 +++-- src/handlers/quickToolsState.js | 12 ++ src/test/editor.tests.js | 239 +++++++++++++++++++++ tests/unit/quickToolsModifierInput.test.ts | 187 ++++++++++++++++ tests/unit/quickToolsState.test.js | 48 +++++ 6 files changed, 556 insertions(+), 16 deletions(-) create mode 100644 tests/unit/quickToolsModifierInput.test.ts diff --git a/src/cm/quickToolsModifierInput.ts b/src/cm/quickToolsModifierInput.ts index 52792a50cb..bd49b4871e 100644 --- a/src/cm/quickToolsModifierInput.ts +++ b/src/cm/quickToolsModifierInput.ts @@ -1,9 +1,18 @@ import type { Extension } from "@codemirror/state"; -import { EditorView, type EditorView as CodeMirrorEditorView } from "@codemirror/view"; +import { + EditorView, + type EditorView as CodeMirrorEditorView, +} from "@codemirror/view"; + +export interface QuickToolsModifierInputContext { + from: number; + to: number; + text: string; +} type QuickToolsModifierInputHandler = ( view: CodeMirrorEditorView, - text: string, + input: QuickToolsModifierInputContext, ) => boolean | void; let handleTextInput: QuickToolsModifierInputHandler = () => false; @@ -14,8 +23,27 @@ export function setQuickToolsModifierInputHandler( handleTextInput = typeof handler === "function" ? handler : () => false; } +/** + * Android may report typing over a selection as an empty deletion followed by + * a separate insertion. Quick-tools modifiers must hold that deletion until + * the character arrives, otherwise shortcuts such as Ctrl+C copy only after + * the selected text has already been removed. + */ +export function isSelectedRangeDeletion( + view: CodeMirrorEditorView, + input: QuickToolsModifierInputContext, +): boolean { + const selection = view?.state?.selection?.main; + if (!selection || selection.empty || input.text !== "") return false; + return input.from <= selection.from && input.to >= selection.to; +} + export default function quickToolsModifierInput(): Extension { - return EditorView.inputHandler.of((view, _from, _to, text) => { - return !!handleTextInput(view, text); + return EditorView.inputHandler.of((view, from, to, text) => { + // When a DOM-derived input is handled without changing state, CodeMirror's + // DOM observer performs its own view.update([]) reconciliation. Dispatching + // here would make that observer think state changed and can leave Android's + // native replacement in the content DOM. + return !!handleTextInput(view, { from, to, text }); }); } diff --git a/src/handlers/quickTools.js b/src/handlers/quickTools.js index aff8f9009e..cd08da4dee 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -7,8 +7,12 @@ import { SearchQuery, setSearchQuery, } from "@codemirror/search"; +import { EditorView } from "@codemirror/view"; import { executeCommand, getRegisteredCommands } from "cm/commandRegistry"; -import { setQuickToolsModifierInputHandler } from "cm/quickToolsModifierInput"; +import { + isSelectedRangeDeletion, + setQuickToolsModifierInputHandler, +} from "cm/quickToolsModifierInput"; import { findQuickToolCommand, mapQuickToolShiftText, @@ -24,6 +28,7 @@ import { clearModifierState, clearQuickToolsButtonFeedback, removeActionStackEntries, + shouldCaptureModifierInput, } from "./quickToolsState"; export let quickToolUsed = false; @@ -62,6 +67,14 @@ quickTools.$input.addEventListener("input", (e) => { quickTools.$input.value = ""; if (!key || key.length > 1) return; const keyCombination = getKeys({ key }); + const codeMirrorView = getCodeMirrorInputView(input); + + if ( + codeMirrorView && + runCodeMirrorQuickToolsTextKey(codeMirrorView, key, keyCombination) + ) { + return; + } if ( keyCombination.shiftKey && @@ -233,10 +246,12 @@ export default function actions(action, value) { state[action] = value; events[action].forEach((cb) => cb(value)); if (Object.values(state).includes(true)) { - if (isCodeMirrorEditorInput(input)) { - editor?.focus(); - } else { + const codeMirrorView = getCodeMirrorInputView(input); + if (shouldCaptureModifierInput(state, Boolean(codeMirrorView))) { + $input.value = ""; $input.focus(); + } else { + codeMirrorView?.focus(); } } else if (input) { input.focus(); @@ -366,25 +381,36 @@ function setInput() { input = activeElement; } -function isCodeMirrorEditorInput(target) { - const { editor, activeFile } = editorManager; - if (!editor || activeFile?.type !== "editor") return false; - const contentDOM = editor.contentDOM; - return target === contentDOM || (contentDOM?.contains?.(target) ?? false); +function getCodeMirrorInputView(target) { + if (!(target instanceof HTMLElement)) return null; + const view = EditorView.findFromDOM(target); + if (!view?.contentDOM) return null; + return target === view.contentDOM || view.contentDOM.contains(target) + ? view + : null; } function runCodeMirrorQuickToolKey(keyCode, keyCombination) { - if (!isCodeMirrorEditorInput(input)) return false; - return runQuickToolKey(editorManager.editor, keyCode, keyCombination); + const view = getCodeMirrorInputView(input); + return view ? runQuickToolKey(view, keyCode, keyCombination) : false; } -export function handleCodeMirrorQuickToolsTextInput(view, text) { +export function handleCodeMirrorQuickToolsTextInput(view, input) { if (!Object.values(state).includes(true)) return false; if (!view?.state || !view.contentDOM) return false; + if (isSelectedRangeDeletion(view, input)) { + setQuicktoolsUsed(); + return true; + } + + const { text } = input; if (!text || text.length !== 1) return false; const keyCombination = getKeys({ key: text }); + return runCodeMirrorQuickToolsTextKey(view, text, keyCombination); +} +function runCodeMirrorQuickToolsTextKey(view, text, keyCombination) { if ( keyCombination.shiftKey && !keyCombination.ctrlKey && diff --git a/src/handlers/quickToolsState.js b/src/handlers/quickToolsState.js index 7301aed0ab..fa9f67a0b9 100644 --- a/src/handlers/quickToolsState.js +++ b/src/handlers/quickToolsState.js @@ -1,5 +1,17 @@ export const modifierKeys = ["shift", "alt", "ctrl", "meta"]; +/** + * CodeMirror can safely receive Shift-only text because replacing the current + * selection is intentional. Command modifiers must capture text outside the + * contenteditable so Android keyboards cannot mutate the document first. + */ +export function shouldCaptureModifierInput(state, isCodeMirrorTarget) { + const hasActiveModifier = modifierKeys.some((key) => Boolean(state[key])); + if (!hasActiveModifier) return false; + if (!isCodeMirrorTarget) return true; + return Boolean(state.ctrl || state.alt || state.meta); +} + export function clearModifierState(state, events = {}) { let changed = false; diff --git a/src/test/editor.tests.js b/src/test/editor.tests.js index e9a6928494..144cc98306 100644 --- a/src/test/editor.tests.js +++ b/src/test/editor.tests.js @@ -35,6 +35,7 @@ import { keyBindingsConflict, toCodeMirrorKey, } from "cm/keyBindingUtils"; +import quickToolsModifierInput from "cm/quickToolsModifierInput"; import { findQuickToolCommand, getShortcutAlternatives, @@ -53,6 +54,11 @@ import { addPointerSelectionRange, getEdgeScrollDirections, } from "cm/touchSelectionMenu"; +import quickTools from "components/quickTools"; +import quickToolsActions, { + clearQuickToolsModifierState, + key as quickToolsKey, +} from "handlers/quickTools"; import keyBindings, { CODEMIRROR_COMMAND_NAMES } from "lib/keyBindings"; import { TestRunner } from "./tester"; @@ -116,6 +122,27 @@ export async function runCodeMirrorTests(writeOutput) { } } + function applyDetectedTextInput(view, { from, to, text }) { + let defaultTransaction; + const getDefaultTransaction = () => { + defaultTransaction ||= view.state.update({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + }); + return defaultTransaction; + }; + const handled = view.state + .facet(EditorView.inputHandler) + .some((handler) => handler(view, from, to, text, getDefaultTransaction)); + if (!handled) view.dispatch(getDefaultTransaction()); + return handled; + } + + function dispatchQuickToolsTextInput(text) { + quickTools.$input.value = text; + quickTools.$input.dispatchEvent(new Event("input", { bubbles: true })); + } + // ========================================= // BASIC EDITOR TESTS // ========================================= @@ -631,6 +658,218 @@ export async function runCodeMirrorTests(writeOutput) { ); }); + runner.test( + "Quick tools Ctrl+C captures Android input outside CodeMirror", + async (test) => { + const doc = 'import { history } from "@codemirror/commands";'; + await withEditor( + test, + async (view) => { + const clipboard = cordova?.plugins?.clipboard; + test.assert( + typeof clipboard?.copy === "function", + "Cordova clipboard should be available", + ); + const originalCopy = clipboard.copy; + let copied = ""; + + try { + clipboard.copy = (text) => { + copied = text; + }; + clearQuickToolsModifierState(); + const from = doc.indexOf("codemirror"); + const to = from + "codemirror".length; + view.dispatch({ selection: { anchor: from, head: to } }); + view.focus(); + quickToolsActions("ctrl"); + + test.assert(quickToolsKey.ctrl, "Ctrl should be armed"); + test.assert( + document.activeElement === quickTools.$input, + "Command modifiers should focus the capture input", + ); + + dispatchQuickToolsTextInput("cc"); + test.assertEqual(view.state.doc.toString(), doc); + test.assertEqual(view.state.selection.main.from, from); + test.assertEqual(view.state.selection.main.to, to); + test.assert( + quickToolsKey.ctrl, + "Invalid captured input should leave Ctrl armed", + ); + + dispatchQuickToolsTextInput("c"); + test.assertEqual(copied, "codemirror"); + test.assertEqual(view.state.doc.toString(), doc); + test.assertEqual(view.state.selection.main.from, from); + test.assertEqual(view.state.selection.main.to, to); + test.assert(!quickToolsKey.ctrl, "Ctrl should reset after Copy"); + test.assert( + document.activeElement === view.contentDOM, + "Editor focus should be restored before Copy runs", + ); + + view.dispatch({ selection: { anchor: 0, head: doc.length } }); + quickToolsActions("ctrl"); + dispatchQuickToolsTextInput("c"); + test.assertEqual(copied, doc); + test.assertEqual(view.state.doc.toString(), doc); + test.assertEqual(view.state.selection.main.from, 0); + test.assertEqual(view.state.selection.main.to, doc.length); + + quickToolsActions("shift"); + test.assert( + document.activeElement === view.contentDOM, + "Shift-only input should stay in CodeMirror", + ); + quickToolsActions("ctrl"); + test.assert( + document.activeElement === quickTools.$input, + "Ctrl+Shift should use the capture input", + ); + quickToolsActions("ctrl"); + test.assert( + document.activeElement === view.contentDOM, + "Turning Ctrl off should restore Shift-only editor focus", + ); + } finally { + clipboard.copy = originalCopy; + clearQuickToolsModifierState(); + } + }, + doc, + [quickToolsModifierInput()], + ); + }, + ); + + runner.test( + "Quick tools modifier fallback holds Android split replacements", + async (test) => { + const doc = 'import { history } from "@codemirror/commands";'; + await withEditor( + test, + async (view) => { + const clipboard = cordova?.plugins?.clipboard; + const originalCopy = clipboard.copy; + let copied = ""; + + try { + clipboard.copy = (text) => { + copied = text; + }; + view.dispatch({ selection: { anchor: 0, head: doc.length } }); + view.focus(); + clearQuickToolsModifierState(); + quickToolsActions("ctrl"); + view.focus(); + + test.assert( + applyDetectedTextInput(view, { + from: 0, + to: doc.length, + text: "", + }), + "Queued Android deletion should be held", + ); + test.assertEqual(view.state.doc.toString(), doc); + test.assert(quickToolsKey.ctrl, "Ctrl should remain armed"); + + test.assert( + applyDetectedTextInput(view, { + from: 0, + to: doc.length, + text: "c", + }), + "Queued Ctrl+C insertion should be handled", + ); + test.assertEqual(copied, doc); + test.assertEqual(view.state.doc.toString(), doc); + test.assertEqual(view.state.selection.main.from, 0); + test.assertEqual(view.state.selection.main.to, doc.length); + test.assert(!quickToolsKey.ctrl, "Ctrl should reset after Copy"); + } finally { + clipboard.copy = originalCopy; + clearQuickToolsModifierState(); + } + }, + doc, + [quickToolsModifierInput()], + ); + }, + ); + + runner.test( + "Quick tools modifier input preserves Copy, Cut, Shift, and multi-selection", + async (test) => { + const doc = 'import { history } from "@codemirror/commands";'; + await withEditor( + test, + async (view) => { + const clipboard = cordova?.plugins?.clipboard; + const originalCopy = clipboard.copy; + let copied = ""; + + try { + clipboard.copy = (text) => { + copied = text; + }; + + const wordFrom = doc.indexOf("codemirror"); + const wordTo = wordFrom + "codemirror".length; + view.dispatch({ selection: { anchor: wordFrom, head: wordTo } }); + view.focus(); + clearQuickToolsModifierState(); + quickToolsActions("ctrl"); + dispatchQuickToolsTextInput("c"); + test.assertEqual(copied, "codemirror"); + test.assertEqual(view.state.doc.toString(), doc); + + view.dispatch({ selection: { anchor: 0, head: 6 } }); + quickToolsActions("ctrl"); + dispatchQuickToolsTextInput("x"); + test.assertEqual(copied, "import"); + test.assertEqual(view.state.doc.toString(), doc.slice(6)); + + quickToolsActions("ctrl"); + dispatchQuickToolsTextInput("z"); + test.assertEqual(view.state.doc.toString(), doc); + + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: "one two three", + }, + selection: EditorSelection.create([ + EditorSelection.range(0, 3), + EditorSelection.range(8, 13), + ]), + }); + quickToolsActions("ctrl"); + dispatchQuickToolsTextInput("c"); + test.assertEqual(copied, "one\nthree"); + test.assertEqual(view.state.doc.toString(), "one two three"); + + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "abc" }, + selection: { anchor: 1, head: 2 }, + }); + quickToolsActions("shift"); + applyDetectedTextInput(view, { from: 1, to: 2, text: "b" }); + test.assertEqual(view.state.doc.toString(), "aBc"); + } finally { + clipboard.copy = originalCopy; + clearQuickToolsModifierState(); + } + }, + doc, + [quickToolsModifierInput()], + ); + }, + ); + runner.test("Every CodeMirror command can be assigned a key", (test) => { const missingCommands = Array.from(CODEMIRROR_COMMAND_NAMES).filter( (name) => !keyBindings[name], diff --git a/tests/unit/quickToolsModifierInput.test.ts b/tests/unit/quickToolsModifierInput.test.ts new file mode 100644 index 0000000000..48742f7752 --- /dev/null +++ b/tests/unit/quickToolsModifierInput.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom + +import { javascript } from "@codemirror/lang-javascript"; +import { + defaultHighlightStyle, + syntaxHighlighting, +} from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it } from "vitest"; +import quickToolsModifierInput, { + isSelectedRangeDeletion, + setQuickToolsModifierInputHandler, + type QuickToolsModifierInputContext, +} from "cm/quickToolsModifierInput"; + +function createView( + doc = "abcdef", + selection = { anchor: 1, head: 4 }, + extraExtensions = [], +) { + const parent = document.createElement("div"); + document.body.append(parent); + const state = EditorState.create({ + doc, + selection, + extensions: [quickToolsModifierInput(), ...extraExtensions], + }); + const view = new EditorView({ state, parent }); + return { view, parent }; +} + +function applyInput( + view: EditorView, + input: QuickToolsModifierInputContext, +): boolean { + const defaultInsert = () => + view.state.update({ + changes: { + from: input.from, + to: input.to, + insert: input.text, + }, + selection: { anchor: input.from + input.text.length }, + }); + const handled = view.state + .facet(EditorView.inputHandler) + .some((handler) => + handler( + view, + input.from, + input.to, + input.text, + defaultInsert, + ), + ); + if (!handled) view.dispatch(defaultInsert()); + return handled; +} + +afterEach(() => { + setQuickToolsModifierInputHandler(() => false); + document.body.replaceChildren(); +}); + +describe("quick-tools modifier input", () => { + it("recognizes only empty changes that cover the active selection", () => { + const { view } = createView(); + + expect( + isSelectedRangeDeletion(view, { from: 1, to: 4, text: "" }), + ).toBe(true); + expect( + isSelectedRangeDeletion(view, { from: 2, to: 4, text: "" }), + ).toBe(false); + expect( + isSelectedRangeDeletion(view, { from: 1, to: 4, text: "c" }), + ).toBe(false); + + view.dispatch({ selection: { anchor: 2 } }); + expect( + isSelectedRangeDeletion(view, { from: 1, to: 4, text: "" }), + ).toBe(false); + view.destroy(); + }); + + it("preserves selection across Android delete-then-copy input", () => { + const doc = 'import { history } from "@codemirror/commands";'; + const { view } = createView(doc, { anchor: 0, head: doc.length }); + let ctrlArmed = true; + let copied = ""; + + setQuickToolsModifierInputHandler((target, input) => { + if (!ctrlArmed) return false; + if (isSelectedRangeDeletion(target, input)) return true; + if (input.text !== "c") return false; + copied = target.state.sliceDoc( + target.state.selection.main.from, + target.state.selection.main.to, + ); + ctrlArmed = false; + return true; + }); + + expect(applyInput(view, { from: 0, to: doc.length, text: "" })).toBe( + true, + ); + expect(view.state.doc.toString()).toBe(doc); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(doc.length); + expect(ctrlArmed).toBe(true); + + expect(applyInput(view, { from: 0, to: doc.length, text: "c" })).toBe( + true, + ); + expect(copied).toBe(doc); + expect(view.state.doc.toString()).toBe(doc); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(doc.length); + expect(ctrlArmed).toBe(false); + view.destroy(); + }); + + it("lets CodeMirror restore a handled syntax-highlighted DOM replacement", async () => { + const doc = 'import { history } from "@codemirror/commands";'; + const from = doc.indexOf("codemirror"); + const to = from + "codemirror".length; + const { view } = createView(doc, { anchor: from, head: to }, [ + javascript(), + syntaxHighlighting(defaultHighlightStyle), + ]); + let receivedInput: QuickToolsModifierInputContext | null = null; + setQuickToolsModifierInputHandler((_target, input) => { + receivedInput = input; + return true; + }); + view.focus(); + await new Promise((resolve) => setTimeout(resolve, 10)); + const startState = view.state; + const stringSpan = Array.from(view.contentDOM.querySelectorAll("span")).find( + (element) => element.textContent?.includes("codemirror"), + ); + const textNode = stringSpan?.firstChild; + expect(textNode).not.toBeNull(); + + if (textNode) textNode.nodeValue = '"@c/commands"'; + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(receivedInput).toEqual({ from, to, text: "c" }); + expect(view.state).toBe(startState); + expect(view.state.doc.toString()).toBe(doc); + expect(view.contentDOM.textContent).toBe(doc); + expect(view.state.selection.main.from).toBe(from); + expect(view.state.selection.main.to).toBe(to); + view.destroy(); + }); + + it("does not add an extra transaction after an editing command", () => { + const updates = []; + const { view } = createView("abcdef", { anchor: 1, head: 4 }, [ + EditorView.updateListener.of((update) => updates.push(update)), + ]); + setQuickToolsModifierInputHandler((target, input) => { + target.dispatch({ + changes: { from: input.from, to: input.to, insert: "" }, + selection: { anchor: input.from }, + }); + return true; + }); + + expect(applyInput(view, { from: 1, to: 4, text: "x" })).toBe(true); + expect(view.state.doc.toString()).toBe("aef"); + expect(updates).toHaveLength(1); + view.destroy(); + }); + + it("allows normal unmodified deletion and typing", () => { + const { view } = createView(); + setQuickToolsModifierInputHandler(() => false); + + expect(applyInput(view, { from: 1, to: 4, text: "" })).toBe(false); + expect(view.state.doc.toString()).toBe("aef"); + expect(applyInput(view, { from: 1, to: 1, text: "z" })).toBe(false); + expect(view.state.doc.toString()).toBe("azef"); + view.destroy(); + }); +}); diff --git a/tests/unit/quickToolsState.test.js b/tests/unit/quickToolsState.test.js index 14059d572a..8b0981d3e4 100644 --- a/tests/unit/quickToolsState.test.js +++ b/tests/unit/quickToolsState.test.js @@ -4,8 +4,56 @@ import { clearQuickToolsButtonFeedback, modifierKeys, removeActionStackEntries, + shouldCaptureModifierInput, } from "handlers/quickToolsState"; +describe("shouldCaptureModifierInput", () => { + const modifiers = (overrides = {}) => ({ + shift: false, + alt: false, + ctrl: false, + meta: false, + ...overrides, + }); + + it("captures command modifiers outside CodeMirror", () => { + expect( + shouldCaptureModifierInput(modifiers({ shift: true }), false), + ).toBe(true); + expect( + shouldCaptureModifierInput(modifiers({ ctrl: true }), false), + ).toBe(true); + }); + + it("keeps Shift-only text in CodeMirror", () => { + expect( + shouldCaptureModifierInput(modifiers({ shift: true }), true), + ).toBe(false); + }); + + it.each(["ctrl", "alt", "meta"])( + "captures CodeMirror input when %s is active", + (key) => { + expect( + shouldCaptureModifierInput(modifiers({ [key]: true }), true), + ).toBe(true); + }, + ); + + it("captures Shift combined with a command modifier", () => { + expect( + shouldCaptureModifierInput( + modifiers({ shift: true, ctrl: true }), + true, + ), + ).toBe(true); + }); + + it("does not capture when every modifier is cleared", () => { + expect(shouldCaptureModifierInput(modifiers(), true)).toBe(false); + }); +}); + describe("clearModifierState", () => { it("clears all modifiers and reports the change", () => { const state = { shift: true, alt: true, ctrl: true, meta: true };