From 8e11cb704d86f4418c236b30c640a0c0c3f31634 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Thu, 2 Jul 2026 04:04:08 +0530 Subject: [PATCH 1/3] feat: improve CodeMirror quick tools modifier support Adds CodeMirror-aware quick tools handling for modifier text input, navigation keys, shift selection, and shortcut command resolution, with focused editor tests covering the new behavior. Also cleans up the advanced HTTP plugin config entry in package.json. --- package.json | 4 +- src/cm/mainEditorExtensions.ts | 2 + src/cm/quickToolsModifierInput.ts | 21 +++ src/cm/quickToolsModifierKeys.ts | 155 +++++++++++++++++++++ src/cm/quickToolsNavigation.ts | 204 +++++++++++++++++++++++++++ src/cm/shiftSelection.ts | 17 +++ src/handlers/quickTools.js | 129 ++++++++++------- src/lib/editorManager.js | 11 +- src/test/editor.tests.js | 223 ++++++++++++++++++++++++++++++ 9 files changed, 709 insertions(+), 57 deletions(-) create mode 100644 src/cm/quickToolsModifierInput.ts create mode 100644 src/cm/quickToolsModifierKeys.ts create mode 100644 src/cm/quickToolsNavigation.ts create mode 100644 src/cm/shiftSelection.ts diff --git a/package.json b/package.json index a35ff6fd0..00d42d70f 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,7 @@ "cordova-plugin-iap": {}, "com.foxdebug.acode.rk.customtabs": {}, "cordova-plugin-system": {}, - "cordova-plugin-advanced-http": { - "ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1" - } + "cordova-plugin-advanced-http": {} }, "platforms": [ "android" diff --git a/src/cm/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index d91c0a2e9..6636ae745 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -9,6 +9,7 @@ interface MainEditorExtensionOptions { pointerCursorVisibilityExtension?: Extension; shiftClickSelectionExtension?: Extension; touchSelectionUpdateExtension?: Extension; + quickToolsModifierInputExtension?: Extension; searchExtension?: Extension; readOnlyExtension?: Extension; optionExtensions?: Extension[]; @@ -47,6 +48,7 @@ export function createMainEditorExtensions( pushExtension(extensions, options.pointerCursorVisibilityExtension); pushExtension(extensions, options.shiftClickSelectionExtension); pushExtension(extensions, options.touchSelectionUpdateExtension); + pushExtension(extensions, options.quickToolsModifierInputExtension); pushExtension(extensions, options.searchExtension); pushExtension(extensions, options.readOnlyExtension); diff --git a/src/cm/quickToolsModifierInput.ts b/src/cm/quickToolsModifierInput.ts new file mode 100644 index 000000000..52792a50c --- /dev/null +++ b/src/cm/quickToolsModifierInput.ts @@ -0,0 +1,21 @@ +import type { Extension } from "@codemirror/state"; +import { EditorView, type EditorView as CodeMirrorEditorView } from "@codemirror/view"; + +type QuickToolsModifierInputHandler = ( + view: CodeMirrorEditorView, + text: string, +) => boolean | void; + +let handleTextInput: QuickToolsModifierInputHandler = () => false; + +export function setQuickToolsModifierInputHandler( + handler: QuickToolsModifierInputHandler, +): void { + handleTextInput = typeof handler === "function" ? handler : () => false; +} + +export default function quickToolsModifierInput(): Extension { + return EditorView.inputHandler.of((view, _from, _to, text) => { + return !!handleTextInput(view, text); + }); +} diff --git a/src/cm/quickToolsModifierKeys.ts b/src/cm/quickToolsModifierKeys.ts new file mode 100644 index 000000000..98d2cbab9 --- /dev/null +++ b/src/cm/quickToolsModifierKeys.ts @@ -0,0 +1,155 @@ +interface QuickToolModifiers { + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; +} + +interface QuickToolCommand { + name: string; + key?: string | null; +} + +type ModifierName = "Ctrl" | "Alt" | "Shift" | "Meta"; + +const modifierAliases: Record = { + ctrl: "Ctrl", + control: "Ctrl", + alt: "Alt", + option: "Alt", + shift: "Shift", + meta: "Meta", + cmd: "Meta", + command: "Meta", + mod: "Ctrl", +}; + +const modifierOrder: ModifierName[] = ["Ctrl", "Alt", "Shift", "Meta"]; + +export function mapQuickToolShiftText(char: string | null | undefined): string { + switch (char) { + case "1": + return "!"; + case "2": + return "@"; + case "3": + return "#"; + case "4": + return "$"; + case "5": + return "%"; + case "6": + return "^"; + case "7": + return "&"; + case "8": + return "*"; + case "9": + return "("; + case "0": + return ")"; + case "-": + return "_"; + case "=": + return "+"; + case "[": + return "{"; + case "]": + return "}"; + case "\\": + return "|"; + case ";": + return ":"; + case "'": + return '"'; + case ",": + return "<"; + case ".": + return ">"; + case "/": + return "?"; + default: + return String(char ?? "").toUpperCase(); + } +} + +export function getQuickToolCombo( + key: string, + modifiers: QuickToolModifiers = {}, +): string | null { + const normalizedKey = normalizeKey(key); + if (!normalizedKey) return null; + + const parts: string[] = []; + if (modifiers.ctrlKey) parts.push("Ctrl"); + if (modifiers.altKey) parts.push("Alt"); + if (modifiers.shiftKey) parts.push("Shift"); + if (modifiers.metaKey) parts.push("Meta"); + parts.push(normalizedKey); + return normalizeShortcutCombo(parts.join("-")); +} + +export function findQuickToolCommand( + commands: QuickToolCommand[], + key: string, + modifiers: QuickToolModifiers = {}, +): QuickToolCommand | null { + const combo = getQuickToolCombo(key, modifiers); + if (!combo) return null; + + return ( + commands.find((command) => + getShortcutAlternatives(command.key).includes(combo), + ) || null + ); +} + +export function getShortcutAlternatives( + keyString: string | null | undefined, +): string[] { + if (!keyString) return []; + return String(keyString) + .split("|") + .map(normalizeShortcutCombo) + .filter((combo): combo is string => Boolean(combo)); +} + +export function normalizeShortcutCombo(combo: string): string | null { + if (!combo) return null; + const parts = splitShortcutCombo(combo); + const modifiers: ModifierName[] = []; + let key: string | null = null; + + parts.forEach((part) => { + const modifier = modifierAliases[part.toLowerCase()]; + if (modifier) { + if (!modifiers.includes(modifier)) modifiers.push(modifier); + return; + } + + key = normalizeKey(part); + }); + + if (!key) return null; + const orderedModifiers = modifierOrder.filter((modifier) => + modifiers.includes(modifier), + ); + return [...orderedModifiers, key].join("-"); +} + +function splitShortcutCombo(combo: string): string[] { + if (combo.endsWith("-")) { + return [...combo.slice(0, -1).split("-").filter(Boolean), "-"]; + } + return combo + .split("-") + .map((part) => part.trim()) + .filter(Boolean); +} + +function normalizeKey(key: string | null | undefined): string | null { + const text = String(key ?? ""); + if (!text) return null; + if (text.length === 1 && /[a-z]/i.test(text)) return text.toUpperCase(); + return text; +} diff --git a/src/cm/quickToolsNavigation.ts b/src/cm/quickToolsNavigation.ts new file mode 100644 index 000000000..915926857 --- /dev/null +++ b/src/cm/quickToolsNavigation.ts @@ -0,0 +1,204 @@ +import { + cursorCharLeft, + cursorCharRight, + cursorDocEnd, + cursorDocStart, + cursorGroupLeft, + cursorGroupRight, + cursorLineBoundaryBackward, + cursorLineBoundaryForward, + cursorLineDown, + cursorLineUp, + cursorPageDown, + cursorPageUp, + deleteCharBackward, + deleteCharForward, + deleteGroupBackward, + deleteGroupForward, + deleteLineBoundaryBackward, + deleteLineBoundaryForward, + selectCharLeft, + selectCharRight, + selectDocEnd, + selectDocStart, + selectGroupLeft, + selectGroupRight, + selectLineBoundaryBackward, + selectLineBoundaryForward, + selectLineDown, + selectLineUp, + selectPageDown, + selectPageUp, +} from "@codemirror/commands"; +import { + type Command, + type EditorView as CodeMirrorEditorView, + runScopeHandlers, +} from "@codemirror/view"; +import createKeyboardEvent from "utils/keyboardEvent"; + +interface QuickToolKeyModifiers { + shiftKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +const keyNames: Record = { + 8: "Backspace", + 9: "Tab", + 13: "Enter", + 27: "Escape", + 33: "PageUp", + 34: "PageDown", + 35: "End", + 36: "Home", + 37: "ArrowLeft", + 38: "ArrowUp", + 39: "ArrowRight", + 40: "ArrowDown", + 46: "Delete", +}; + +const plainMovementCommands: Record = { + 37: cursorCharLeft, + 38: cursorLineUp, + 39: cursorCharRight, + 40: cursorLineDown, + 33: cursorPageUp, + 34: cursorPageDown, + 35: cursorLineBoundaryForward, + 36: cursorLineBoundaryBackward, +}; + +const shiftMovementCommands: Record = { + 37: selectCharLeft, + 38: selectLineUp, + 39: selectCharRight, + 40: selectLineDown, + 33: selectPageUp, + 34: selectPageDown, + 35: selectLineBoundaryForward, + 36: selectLineBoundaryBackward, +}; + +const ctrlMovementCommands: Record = { + 37: cursorGroupLeft, + 38: cursorPageUp, + 39: cursorGroupRight, + 40: cursorPageDown, + 35: cursorDocEnd, + 36: cursorDocStart, +}; + +const ctrlShiftMovementCommands: Record = { + 37: selectGroupLeft, + 38: selectPageUp, + 39: selectGroupRight, + 40: selectPageDown, + 35: selectDocEnd, + 36: selectDocStart, +}; + +const metaMovementCommands: Record = { + 37: cursorLineBoundaryBackward, + 38: cursorDocStart, + 39: cursorLineBoundaryForward, + 40: cursorDocEnd, + 35: cursorDocEnd, + 36: cursorDocStart, +}; + +const metaShiftMovementCommands: Record = { + 37: selectLineBoundaryBackward, + 38: selectDocStart, + 39: selectLineBoundaryForward, + 40: selectDocEnd, + 35: selectDocEnd, + 36: selectDocStart, +}; + +export function createQuickToolKeyEvent( + keyCode: number, + modifiers: QuickToolKeyModifiers = {}, +): KeyboardEvent { + const key = keyNames[keyCode] || String.fromCharCode(keyCode); + return createKeyboardEvent("keydown", { + type: "keydown", + key, + keyCode, + which: keyCode, + bubbles: true, + cancelable: true, + shiftKey: !!modifiers.shiftKey, + ctrlKey: !!modifiers.ctrlKey, + altKey: !!modifiers.altKey, + metaKey: !!modifiers.metaKey, + }) as KeyboardEvent; +} + +export function runQuickToolKey( + view: CodeMirrorEditorView, + keyCode: number, + modifiers: QuickToolKeyModifiers = {}, +): boolean { + if (!view?.state || typeof view.focus !== "function") return false; + + const event = createQuickToolKeyEvent(keyCode, modifiers); + if (runScopeHandlers(view, event, "editor")) { + view.focus(); + return true; + } + + const command = getFallbackCommand(keyCode, modifiers); + if (!command) return false; + const handled = command(view); + if (handled !== false) { + view.focus(); + return true; + } + + return false; +} + +export const runQuickToolNavigation = runQuickToolKey; + +function getFallbackCommand( + keyCode: number, + modifiers: QuickToolKeyModifiers = {}, +): Command | undefined { + if (keyCode === 46) return getDeleteForwardCommand(modifiers); + if (keyCode === 8) return getDeleteBackwardCommand(modifiers); + + if (modifiers.metaKey) { + return modifiers.shiftKey + ? metaShiftMovementCommands[keyCode] + : metaMovementCommands[keyCode]; + } + + if (modifiers.ctrlKey || modifiers.altKey) { + return modifiers.shiftKey + ? ctrlShiftMovementCommands[keyCode] + : ctrlMovementCommands[keyCode]; + } + + return modifiers.shiftKey + ? shiftMovementCommands[keyCode] + : plainMovementCommands[keyCode]; +} + +function getDeleteForwardCommand( + modifiers: QuickToolKeyModifiers = {}, +): Command { + if (modifiers.metaKey) return deleteLineBoundaryForward; + if (modifiers.ctrlKey || modifiers.altKey) return deleteGroupForward; + return deleteCharForward; +} + +function getDeleteBackwardCommand( + modifiers: QuickToolKeyModifiers = {}, +): Command { + if (modifiers.metaKey) return deleteLineBoundaryBackward; + if (modifiers.ctrlKey || modifiers.altKey) return deleteGroupBackward; + return deleteCharBackward; +} diff --git a/src/cm/shiftSelection.ts b/src/cm/shiftSelection.ts new file mode 100644 index 000000000..417c79a59 --- /dev/null +++ b/src/cm/shiftSelection.ts @@ -0,0 +1,17 @@ +interface ShiftSelectionOptions { + event?: { + shiftKey?: boolean; + }; + quickToolsShift?: boolean; + shiftClickSelection?: boolean; +} + +export function isShiftSelectionActive({ + event, + quickToolsShift, + shiftClickSelection, +}: ShiftSelectionOptions = {}): boolean { + if (quickToolsShift) return true; + if (!shiftClickSelection) return false; + return !!event?.shiftKey; +} diff --git a/src/handlers/quickTools.js b/src/handlers/quickTools.js index af6c39350..4758f9d14 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -7,7 +7,13 @@ import { SearchQuery, setSearchQuery, } from "@codemirror/search"; -import { executeCommand } from "cm/commandRegistry"; +import { executeCommand, getRegisteredCommands } from "cm/commandRegistry"; +import { setQuickToolsModifierInputHandler } from "cm/quickToolsModifierInput"; +import { + findQuickToolCommand, + mapQuickToolShiftText, +} from "cm/quickToolsModifierKeys"; +import { runQuickToolKey } from "cm/quickToolsNavigation"; import quickTools from "components/quickTools"; import actionStack from "lib/actionStack"; import searchHistory from "lib/searchHistory"; @@ -37,6 +43,8 @@ const events = { meta: [], }; +setQuickToolsModifierInputHandler(handleCodeMirrorQuickToolsTextInput); + /** * @typedef { 'shift' | 'alt' | 'ctrl' | 'meta' } QuickToolsEvent * @typedef {(value: boolean)=>void} QuickToolsEventListener @@ -212,7 +220,11 @@ export default function actions(action, value) { state[action] = value; events[action].forEach((cb) => cb(value)); if (Object.values(state).includes(true)) { - $input.focus(); + if (isCodeMirrorEditorInput(input)) { + editor?.focus(); + } else { + $input.focus(); + } } else if (input) { input.focus(); } else { @@ -235,13 +247,12 @@ export default function actions(action, value) { case "key": { value = Number.parseInt(value, 10); - if (value < 37 || value > 40) { - resetKeys(); - } + const keyCombination = getKeys({ keyCode: value }); setInput(); - getInput().dispatchEvent( - KeyboardEvent("keydown", getKeys({ keyCode: value })), - ); + if (runCodeMirrorQuickToolKey(value, keyCombination)) { + return true; + } + getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); return true; } @@ -337,6 +348,63 @@ 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 runCodeMirrorQuickToolKey(keyCode, keyCombination) { + if (!isCodeMirrorEditorInput(input)) return false; + return runQuickToolKey(editorManager.editor, keyCode, keyCombination); +} + +export function handleCodeMirrorQuickToolsTextInput(view, text) { + if (!Object.values(state).includes(true)) return; + if (!view?.state || !view.contentDOM) return false; + if (!text || text.length !== 1) return; + + const keyCombination = getKeys({ key: text }); + input = view.contentDOM; + + if ( + keyCombination.shiftKey && + !keyCombination.ctrlKey && + !keyCombination.altKey && + !keyCombination.metaKey + ) { + resetKeys(); + view.dispatch(view.state.replaceSelection(mapQuickToolShiftText(text))); + setQuicktoolsUsed(); + return true; + } + + if ( + !keyCombination.ctrlKey && + !keyCombination.altKey && + !keyCombination.metaKey + ) { + return false; + } + + resetKeys(); + + const command = findQuickToolCommand( + getRegisteredCommands(), + text, + keyCombination, + ); + if (command && executeCommand(command.name, view)) { + setQuicktoolsUsed(); + return true; + } + + getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); + setQuicktoolsUsed(); + return true; +} + function toggleSearch() { const $footer = quickTools.$footer; const $searchRow1 = quickTools.$searchRow1; @@ -708,50 +776,7 @@ function insertText(value) { } function shiftKeyMapping(char) { - switch (char) { - case "1": - return "!"; - case "2": - return "@"; - case "3": - return "#"; - case "4": - return "$"; - case "5": - return "%"; - case "6": - return "^"; - case "7": - return "&"; - case "8": - return "*"; - case "9": - return "("; - case "0": - return ")"; - case "-": - return "_"; - case "=": - return "+"; - case "[": - return "{"; - case "]": - return "}"; - case "\\": - return "|"; - case ";": - return ":"; - case "'": - return '"'; - case ",": - return "<"; - case ".": - return ">"; - case "/": - return "?"; - default: - return char.toUpperCase(); - } + return mapQuickToolShiftText(char); } function getRefValue(ref) { diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 3f96314b0..b6ce9140f 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -67,8 +67,10 @@ import { } from "cm/editorUtils"; import indentGuides from "cm/indentGuides"; import { lineBreakMarker } from "cm/lineBreakMarker"; +import quickToolsModifierInput from "cm/quickToolsModifierInput"; import rainbowBrackets, { getRainbowBracketColors } from "cm/rainbowBrackets"; import scrollPastEndCustom from "cm/scrollPastEnd"; +import { isShiftSelectionActive as resolveShiftSelectionActive } from "cm/shiftSelection"; import tagAutoRename from "cm/tagAutoRename"; import { getThemeConfig, getThemeExtensions } from "cm/themes"; import list from "components/collapsableList"; @@ -215,8 +217,11 @@ async function EditorManager($header, $body) { }, ); const isShiftSelectionActive = (event) => { - if (!appSettings.value.shiftClickSelection) return false; - return !!event?.shiftKey || quickTools?.$footer?.dataset?.shift != null; + return resolveShiftSelectionActive({ + event, + quickToolsShift: quickTools?.$footer?.dataset?.shift != null, + shiftClickSelection: appSettings.value.shiftClickSelection, + }); }; const shiftClickSelectionExtension = EditorView.domEventHandlers({ click(event) { @@ -951,6 +956,7 @@ async function EditorManager($header, $body) { pointerCursorVisibilityExtension, shiftClickSelectionExtension, touchSelectionUpdateExtension, + quickToolsModifierInputExtension: quickToolsModifierInput(), searchExtension: search(), // Ensure read-only can be toggled later via compartment readOnlyExtension: readOnlyCompartment.of(EditorState.readOnly.of(false)), @@ -1515,6 +1521,7 @@ async function EditorManager($header, $body) { pointerCursorVisibilityExtension, shiftClickSelectionExtension, touchSelectionUpdateExtension, + quickToolsModifierInputExtension: quickToolsModifierInput(), searchExtension: search(), // Keep dynamic compartments across state swaps optionExtensions: getBaseExtensionsFromOptions(), diff --git a/src/test/editor.tests.js b/src/test/editor.tests.js index b039524be..d7f5eb39a 100644 --- a/src/test/editor.tests.js +++ b/src/test/editor.tests.js @@ -10,6 +10,17 @@ import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import createBaseExtensions from "cm/baseExtensions"; import indentGuides from "cm/indentGuides"; +import { + findQuickToolCommand, + getShortcutAlternatives, + mapQuickToolShiftText, +} from "cm/quickToolsModifierKeys"; +import { + createQuickToolKeyEvent, + runQuickToolKey, + runQuickToolNavigation, +} from "cm/quickToolsNavigation"; +import { isShiftSelectionActive } from "cm/shiftSelection"; import { getEdgeScrollDirections } from "cm/touchSelectionMenu"; import { TestRunner } from "./tester"; @@ -220,6 +231,218 @@ export async function runCodeMirrorTests(writeOutput) { }); }); + runner.test( + "Quick tools arrow moves cursor without selection", + async (test) => { + await withEditor(test, async (view) => { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "abc" }, + selection: EditorSelection.cursor(0), + }); + + const handled = runQuickToolNavigation(view, 39, { shiftKey: false }); + const main = view.state.selection.main; + + test.assert(handled, "Right arrow should be handled"); + test.assert(main.empty, "Selection should stay empty"); + test.assertEqual(main.head, 1); + }); + }, + ); + + runner.test("Quick tools Shift+Right extends selection", async (test) => { + await withEditor(test, async (view) => { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "abc" }, + selection: EditorSelection.cursor(0), + }); + + const handled = runQuickToolNavigation(view, 39, { shiftKey: true }); + const main = view.state.selection.main; + + test.assert(handled, "Shift+Right should be handled"); + test.assertEqual(main.anchor, 0); + test.assertEqual(main.head, 1); + test.assertEqual(view.state.sliceDoc(main.from, main.to), "a"); + }); + }); + + runner.test("Quick tools Ctrl+Right moves by word", async (test) => { + await withEditor(test, async (view) => { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "one two" }, + selection: EditorSelection.cursor(0), + }); + + const handled = runQuickToolKey(view, 39, { ctrlKey: true }); + const main = view.state.selection.main; + + test.assert(handled, "Ctrl+Right should be handled"); + test.assert(main.empty, "Selection should stay empty"); + test.assert( + main.head > 1, + "Ctrl+Right should move farther than one character", + ); + }); + }); + + runner.test("Quick tools Ctrl+Shift+Right selects by word", async (test) => { + await withEditor(test, async (view) => { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "one two" }, + selection: EditorSelection.cursor(0), + }); + + const handled = runQuickToolKey(view, 39, { + ctrlKey: true, + shiftKey: true, + }); + const main = view.state.selection.main; + + test.assert(handled, "Ctrl+Shift+Right should be handled"); + test.assertEqual(main.anchor, 0); + test.assert( + main.head > 1, + "Ctrl+Shift+Right should select farther than one character", + ); + }); + }); + + runner.test("Quick tools Shift+Home selects to line start", async (test) => { + await withEditor(test, async (view) => { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "abc\ndef" }, + selection: EditorSelection.cursor(6), + }); + + const handled = runQuickToolKey(view, 36, { shiftKey: true }); + const main = view.state.selection.main; + + test.assert(handled, "Shift+Home should be handled"); + test.assertEqual(main.anchor, 6); + test.assertEqual(main.head, 4); + }); + }); + + runner.test("Quick tools key events preserve modifiers", (test) => { + const event = createQuickToolKeyEvent(39, { + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: true, + }); + + test.assertEqual(event.key, "ArrowRight"); + test.assertEqual(event.keyCode, 39); + test.assert(event.ctrlKey, "Ctrl should be preserved"); + test.assert(event.shiftKey, "Shift should be preserved"); + test.assert(event.altKey, "Alt should be preserved"); + test.assert(event.metaKey, "Meta should be preserved"); + }); + + runner.test("Quick tools unsupported key falls back", async (test) => { + await withEditor(test, async (view) => { + const handled = runQuickToolKey(view, 112, { + ctrlKey: true, + shiftKey: true, + }); + test.assert(!handled, "Unsupported key should not be handled"); + }); + }); + + runner.test( + "Quick tools Shift+Up and Shift+Down extend selection", + async (test) => { + await withEditor(test, async (view) => { + const doc = "abc\ndef\nghi"; + const line2 = 5; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: doc }, + selection: EditorSelection.cursor(line2), + }); + + const downHandled = runQuickToolNavigation(view, 40, { + shiftKey: true, + }); + let main = view.state.selection.main; + test.assert(downHandled, "Shift+Down should be handled"); + test.assertEqual(main.anchor, line2); + test.assertEqual(main.head, 9); + + view.dispatch({ selection: EditorSelection.cursor(line2) }); + const upHandled = runQuickToolNavigation(view, 38, { shiftKey: true }); + main = view.state.selection.main; + test.assert(upHandled, "Shift+Up should be handled"); + test.assertEqual(main.anchor, line2); + test.assertEqual(main.head, 1); + }); + }, + ); + + runner.test( + "Quick tools Shift enables pointer selection independently", + (test) => { + test.assert( + isShiftSelectionActive({ + event: { shiftKey: false }, + quickToolsShift: true, + shiftClickSelection: false, + }), + "Quick tools Shift should bypass shift-click setting", + ); + }, + ); + + runner.test("Physical Shift pointer selection respects setting", (test) => { + test.assert( + !isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + shiftClickSelection: false, + }), + "Physical Shift should be ignored when setting is disabled", + ); + test.assert( + isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + shiftClickSelection: true, + }), + "Physical Shift should work when setting is enabled", + ); + }); + + runner.test("Quick tools Shift maps printable text", (test) => { + test.assertEqual(mapQuickToolShiftText("a"), "A"); + test.assertEqual(mapQuickToolShiftText("1"), "!"); + test.assertEqual(mapQuickToolShiftText("/"), "?"); + }); + + runner.test("Quick tools modifier combos resolve commands", (test) => { + const commands = [ + { name: "selectall", key: "Ctrl-A" }, + { name: "saveFile", key: "Ctrl-S" }, + { name: "redo", key: "Ctrl-Shift-Z|Ctrl-Y" }, + ]; + + test.assertEqual( + findQuickToolCommand(commands, "a", { ctrlKey: true })?.name, + "selectall", + ); + test.assertEqual( + findQuickToolCommand(commands, "s", { ctrlKey: true })?.name, + "saveFile", + ); + test.assertEqual( + findQuickToolCommand(commands, "y", { ctrlKey: true })?.name, + "redo", + ); + test.assertEqual( + getShortcutAlternatives("Ctrl-Shift-Z|Ctrl-Y").join(","), + "Ctrl-Shift-Z,Ctrl-Y", + ); + }); + // ========================================= // HISTORY (UNDO/REDO) TESTS // ========================================= From 7737e13ead3d65f62f3b047535b7d6c4fa3c1bdd Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Thu, 2 Jul 2026 04:54:02 +0530 Subject: [PATCH 2/3] fix: stabilize CodeMirror quick-tools modifiers - Add CodeMirror-aware quick-tools modifier input, navigation, shift tap selection, and multi-cursor handling. - Keep editor focus/caret visible while quick-tools modifiers are active. - Hide unreliable Shift tap/click setting for now. - Clear stuck quick-toolbar modifier/active state around search open/close. - Add focused tests for modifier combos, pointer selection, multi-cursor, and search cleanup. --- src/cm/mainEditorExtensions.ts | 2 + src/cm/shiftSelection.ts | 29 +++++++- src/cm/touchSelectionMenu.js | 90 +++++++++++++++++------- src/handlers/quickTools.js | 59 ++++++++++++---- src/handlers/quickToolsState.js | 57 +++++++++++++++ src/lib/editorManager.js | 26 ++++++- src/lib/settings.js | 2 +- src/settings/editorSettings.js | 7 -- src/test/editor.tests.js | 119 ++++++++++++++++++++++++++++---- src/test/sanity.tests.js | 70 +++++++++++++++++++ 10 files changed, 397 insertions(+), 64 deletions(-) create mode 100644 src/handlers/quickToolsState.js diff --git a/src/cm/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index 6636ae745..e445bf9b4 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -8,6 +8,7 @@ interface MainEditorExtensionOptions { themeExtension?: Extension; pointerCursorVisibilityExtension?: Extension; shiftClickSelectionExtension?: Extension; + multiCursorSelectionExtension?: Extension; touchSelectionUpdateExtension?: Extension; quickToolsModifierInputExtension?: Extension; searchExtension?: Extension; @@ -47,6 +48,7 @@ export function createMainEditorExtensions( extensions.push(fixedHeightTheme); pushExtension(extensions, options.pointerCursorVisibilityExtension); pushExtension(extensions, options.shiftClickSelectionExtension); + pushExtension(extensions, options.multiCursorSelectionExtension); pushExtension(extensions, options.touchSelectionUpdateExtension); pushExtension(extensions, options.quickToolsModifierInputExtension); pushExtension(extensions, options.searchExtension); diff --git a/src/cm/shiftSelection.ts b/src/cm/shiftSelection.ts index 417c79a59..1f5cc2bad 100644 --- a/src/cm/shiftSelection.ts +++ b/src/cm/shiftSelection.ts @@ -1,17 +1,40 @@ interface ShiftSelectionOptions { event?: { shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; }; quickToolsShift?: boolean; + quickToolsCtrl?: boolean; + quickToolsMeta?: boolean; shiftClickSelection?: boolean; + isMac?: boolean; } -export function isShiftSelectionActive({ +export function isRangeSelectionActive({ event, quickToolsShift, - shiftClickSelection, }: ShiftSelectionOptions = {}): boolean { if (quickToolsShift) return true; - if (!shiftClickSelection) return false; return !!event?.shiftKey; } + +export function isMultiCursorSelectionActive({ + event, + quickToolsCtrl, + quickToolsMeta, + isMac = isMacPlatform(), +}: ShiftSelectionOptions = {}): boolean { + if (quickToolsCtrl || quickToolsMeta) return true; + return isMac ? !!event?.metaKey : !!event?.ctrlKey; +} + +export const isShiftSelectionActive = isRangeSelectionActive; + +function isMacPlatform(): boolean { + const platform = navigator?.platform || ""; + return ( + /Mac|iPhone|iPad|iPod/i.test(platform) || + (platform === "MacIntel" && navigator.maxTouchPoints > 1) + ); +} diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index 44a9e761c..4b8767f69 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -115,6 +115,20 @@ export function getEdgeScrollDirections(options) { return { horizontal, vertical }; } +/** + * Add a cursor or range to an existing CodeMirror selection. + * @param {EditorSelection} selection + * @param {{anchor:number, head:number, extend?:boolean}} options + * @returns {EditorSelection} + */ +export function addPointerSelectionRange(selection, options) { + const { anchor, head, extend = false } = options; + const range = extend + ? EditorSelection.range(anchor, head) + : EditorSelection.cursor(head); + return selection.addRange(range); +} + function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } @@ -128,11 +142,12 @@ class TouchSelectionMenuController { #container; #getActiveFile; #isShiftSelectionActive; + #isMultiCursorSelectionActive; #stateSyncRaf = 0; #isScrolling = false; #isPointerInteracting = false; - #shiftSelectionSession = null; - #pendingShiftSelectionClick = null; + #pointerSelectionSession = null; + #pendingPointerSelectionClick = null; #menuActive = false; #menuRequested = false; #enabled = true; @@ -147,6 +162,8 @@ class TouchSelectionMenuController { this.#getActiveFile = options.getActiveFile || (() => null); this.#isShiftSelectionActive = options.isShiftSelectionActive || (() => false); + this.#isMultiCursorSelectionActive = + options.isMultiCursorSelectionActive || (() => false); this.$menu = document.createElement("menu"); this.$menu.className = "cursor-menu"; this.#bindEvents(); @@ -195,8 +212,8 @@ class TouchSelectionMenuController { this.#clearMenuShowTimer(); cancelAnimationFrame(this.#stateSyncRaf); this.#stateSyncRaf = 0; - this.#shiftSelectionSession = null; - this.#pendingShiftSelectionClick = null; + this.#pointerSelectionSession = null; + this.#pendingPointerSelectionClick = null; this.#tooltipObserver?.disconnect(); this.#hideMenu(true); } @@ -204,8 +221,8 @@ class TouchSelectionMenuController { setEnabled(enabled) { this.#enabled = !!enabled; if (this.#enabled) return; - this.#shiftSelectionSession = null; - this.#pendingShiftSelectionClick = null; + this.#pointerSelectionSession = null; + this.#pendingPointerSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; this.#isScrolling = false; @@ -273,8 +290,8 @@ class TouchSelectionMenuController { onSessionChanged() { if (!this.#enabled) return; - this.#shiftSelectionSession = null; - this.#pendingShiftSelectionClick = null; + this.#pointerSelectionSession = null; + this.#pendingPointerSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; this.#isScrolling = false; @@ -295,16 +312,16 @@ class TouchSelectionMenuController { const target = event.target; if (this.$menu.contains(target)) return; if (this.#isIgnoredPointerTarget(target)) { - this.#shiftSelectionSession = null; + this.#pointerSelectionSession = null; return; } if (target instanceof Node && this.#view.dom.contains(target)) { - this.#captureShiftSelection(event); + this.#capturePointerSelection(event); this.#isPointerInteracting = true; this.#clearMenuShowTimer(); return; } - this.#shiftSelectionSession = null; + this.#pointerSelectionSession = null; this.#isPointerInteracting = false; this.#menuRequested = false; this.#hideMenu(); @@ -312,9 +329,9 @@ class TouchSelectionMenuController { #onGlobalPointerUp = (event) => { if (event.type === "pointerup") { - this.#commitShiftSelection(event); + this.#commitPointerSelection(event); } else { - this.#shiftSelectionSession = null; + this.#pointerSelectionSession = null; } if (!this.#isPointerInteracting) return; this.#isPointerInteracting = false; @@ -329,25 +346,27 @@ class TouchSelectionMenuController { this.#hideMenu(); }; - #captureShiftSelection(event) { - if (!this.#canExtendSelection(event)) { - this.#shiftSelectionSession = null; + #capturePointerSelection(event) { + if (!this.#canHandlePointerSelection(event)) { + this.#pointerSelectionSession = null; return; } - this.#shiftSelectionSession = { + this.#pointerSelectionSession = { pointerId: event.pointerId, anchor: this.#view.state.selection.main.anchor, + extend: this.#canExtendSelection(event), + addRange: this.#canAddSelectionRange(event), x: event.clientX, y: event.clientY, }; } - #commitShiftSelection(event) { - const session = this.#shiftSelectionSession; - this.#shiftSelectionSession = null; + #commitPointerSelection(event) { + const session = this.#pointerSelectionSession; + this.#pointerSelectionSession = null; if (!session) return; - if (!this.#canExtendSelection(event)) return; + if (!this.#canHandlePointerSelection(event)) return; if (event.pointerId !== session.pointerId) return; if ( Math.hypot(event.clientX - session.x, event.clientY - session.y) > @@ -365,11 +384,19 @@ class TouchSelectionMenuController { { x: event.clientX, y: event.clientY }, false, ); + if (head == null) return; + const selection = session.addRange + ? addPointerSelectionRange(this.#view.state.selection, { + anchor: session.anchor, + head, + extend: session.extend, + }) + : EditorSelection.range(session.anchor, head); this.#view.dispatch({ - selection: EditorSelection.range(session.anchor, head), - userEvent: "select.extend", + selection, + userEvent: session.addRange ? "select.pointer" : "select.extend", }); - this.#pendingShiftSelectionClick = { + this.#pendingPointerSelectionClick = { x: event.clientX, y: event.clientY, timeStamp: event.timeStamp, @@ -377,6 +404,10 @@ class TouchSelectionMenuController { event.preventDefault(); } + #canHandlePointerSelection(event) { + return this.#canExtendSelection(event) || this.#canAddSelectionRange(event); + } + #canExtendSelection(event) { if (!this.#enabled) return false; if (!(event.isTrusted && event.isPrimary)) return false; @@ -384,9 +415,16 @@ class TouchSelectionMenuController { return !!this.#isShiftSelectionActive(event); } + #canAddSelectionRange(event) { + if (!this.#enabled) return false; + if (!(event.isTrusted && event.isPrimary)) return false; + if (typeof event.button === "number" && event.button !== 0) return false; + return !!this.#isMultiCursorSelectionActive(event); + } + consumePendingShiftSelectionClick(event) { - const pending = this.#pendingShiftSelectionClick; - this.#pendingShiftSelectionClick = null; + const pending = this.#pendingPointerSelectionClick; + this.#pendingPointerSelectionClick = null; if (!pending || !this.#enabled) return false; if (event.timeStamp - pending.timeStamp > TAP_MAX_DELAY) return false; if ( diff --git a/src/handlers/quickTools.js b/src/handlers/quickTools.js index 4758f9d14..09654465b 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -20,6 +20,11 @@ import searchHistory from "lib/searchHistory"; import appSettings from "lib/settings"; import searchSettings from "settings/searchSettings"; import KeyboardEvent from "utils/keyboardEvent"; +import { + clearModifierState, + clearQuickToolsButtonFeedback, + removeActionStackEntries, +} from "./quickToolsState"; export let quickToolUsed = false; @@ -202,6 +207,12 @@ export const key = { }, }; +export function clearQuickToolsModifierState({ restoreFocus = false } = {}) { + const changed = clearModifierState(state, events); + if (restoreFocus) input?.focus?.(); + return changed; +} + /** * Performs quick actions * @param {string} action Action to perform @@ -415,6 +426,8 @@ function toggleSearch() { const selectedText = getSelectedText(editor); if (!$footer.contains($searchRow1)) { + removeSearchBarActions(); + clearSearchQuickToolsState(); const { className } = quickTools.$toggler; const $content = [...$footer.children]; const footerHeight = getFooterHeight(); @@ -422,6 +435,7 @@ function toggleSearch() { $toggler.className = "floating icon clearclose"; $footer.content = [$searchRow1, $searchRow2]; + clearSearchQuickToolsState($content); setRefValue($searchInput, selectedText || ""); $searchInput.oninput = function () { @@ -451,10 +465,13 @@ function toggleSearch() { content: $content, footerHeight, }; + clearSearchQuickToolsState(restoreState.content); removeSearch(); + clearQuickToolsButtonFeedback(restoreState.content); $footer.content = restoreState.content; $toggler.className = restoreState.className; setFooterHeight(restoreState.footerHeight); + clearSearchQuickToolsState(restoreState.content); activeSearchState = null; }, }); @@ -466,7 +483,7 @@ function toggleSearch() { return; } - actionStack.get("search-bar").action(); + actionStack.get("search-bar")?.action?.(); } $searchInput.focus(); @@ -509,6 +526,7 @@ function setHeight(height = 1, save = true) { if (height === 0) { searchBar.action(); } else { + clearSearchQuickToolsState(activeSearchState?.content); const footerHeight = Number(height) || 0; activeSearchState = { className: @@ -516,6 +534,7 @@ function setHeight(height = 1, save = true) { content: getQuickToolsRows(footerHeight), footerHeight, }; + clearQuickToolsButtonFeedback(activeSearchState.content); if (save) { appSettings.update({ quickTools: height }, false); } @@ -563,9 +582,11 @@ function getQuickToolsRows(height) { */ function removeSearch() { const { $footer, $searchRow1, $searchRow2 } = quickTools; + const hasSearchRows = $footer.contains($searchRow1); - if (!$footer.contains($searchRow1)) return; - actionStack.remove("search-bar"); + removeSearchBarActions(); + if (!hasSearchRows) return; + clearSearchQuickToolsState(); $footer.removeAttribute("data-searching"); $searchRow1.remove(); $searchRow2.remove(); @@ -712,15 +733,29 @@ function focusEditor() { } function resetKeys() { - state.shift = false; - events.shift.forEach((cb) => cb(false)); - state.alt = false; - events.alt.forEach((cb) => cb(false)); - state.ctrl = false; - events.ctrl.forEach((cb) => cb(false)); - state.meta = false; - events.meta.forEach((cb) => cb(false)); - input?.focus?.(); + clearQuickToolsModifierState({ restoreFocus: true }); +} + +function clearSearchQuickToolsState(extraContainers = []) { + clearQuickToolsModifierState(); + clearQuickToolsButtonFeedback( + getQuickToolsFeedbackContainers(extraContainers), + ); +} + +function getQuickToolsFeedbackContainers(extraContainers = []) { + const { $footer, $row1, $row2 } = quickTools; + return [ + $footer, + $row1, + $row2, + ...(activeSearchState?.content || []), + ...(Array.isArray(extraContainers) ? extraContainers : [extraContainers]), + ]; +} + +function removeSearchBarActions() { + return removeActionStackEntries(actionStack, "search-bar"); } /** diff --git a/src/handlers/quickToolsState.js b/src/handlers/quickToolsState.js new file mode 100644 index 000000000..6b025c2e6 --- /dev/null +++ b/src/handlers/quickToolsState.js @@ -0,0 +1,57 @@ +export const modifierKeys = ["shift", "alt", "ctrl", "meta"]; + +export function clearModifierState(state, events = {}) { + let changed = false; + + for (const key of modifierKeys) { + if (state[key]) changed = true; + state[key] = false; + events[key]?.forEach((callback) => callback(false)); + } + + return changed; +} + +export function clearQuickToolsButtonFeedback(containers = []) { + const visited = new Set(); + let cleared = 0; + + for (const container of containers) { + if (!container || visited.has(container)) continue; + visited.add(container); + + const buttons = [ + ...(container.matches?.(".active, .click, [data-timeout]") + ? [container] + : []), + ...(container.querySelectorAll?.(".active, .click, [data-timeout]") || + []), + ]; + + for (const button of buttons) { + if (!button || visited.has(button)) continue; + visited.add(button); + if (button.dataset?.timeout) { + clearTimeout(Number(button.dataset.timeout)); + delete button.dataset.timeout; + } + if ( + button.classList?.contains("active") || + button.classList?.contains("click") + ) { + button.classList.remove("active", "click"); + cleared++; + } + } + } + + return cleared; +} + +export function removeActionStackEntries(actionStack, id) { + let removed = 0; + while (actionStack.remove(id)) { + removed++; + } + return removed; +} diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index b6ce9140f..0cd29b308 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -70,7 +70,10 @@ import { lineBreakMarker } from "cm/lineBreakMarker"; import quickToolsModifierInput from "cm/quickToolsModifierInput"; import rainbowBrackets, { getRainbowBracketColors } from "cm/rainbowBrackets"; import scrollPastEndCustom from "cm/scrollPastEnd"; -import { isShiftSelectionActive as resolveShiftSelectionActive } from "cm/shiftSelection"; +import { + isMultiCursorSelectionActive as resolveMultiCursorSelectionActive, + isShiftSelectionActive as resolveShiftSelectionActive, +} from "cm/shiftSelection"; import tagAutoRename from "cm/tagAutoRename"; import { getThemeConfig, getThemeExtensions } from "cm/themes"; import list from "components/collapsableList"; @@ -220,7 +223,20 @@ async function EditorManager($header, $body) { return resolveShiftSelectionActive({ event, quickToolsShift: quickTools?.$footer?.dataset?.shift != null, - shiftClickSelection: appSettings.value.shiftClickSelection, + }); + }; + const isMultiCursorSelectionActive = (event) => { + return resolveMultiCursorSelectionActive({ + event, + quickToolsCtrl: quickTools?.$footer?.dataset?.ctrl != null, + quickToolsMeta: quickTools?.$footer?.dataset?.meta != null, + }); + }; + const isQuickToolsMultiCursorSelectionActive = () => { + return resolveMultiCursorSelectionActive({ + quickToolsCtrl: quickTools?.$footer?.dataset?.ctrl != null, + quickToolsMeta: quickTools?.$footer?.dataset?.meta != null, + isMac: false, }); }; const shiftClickSelectionExtension = EditorView.domEventHandlers({ @@ -232,6 +248,9 @@ async function EditorManager($header, $body) { return true; }, }); + const multiCursorSelectionExtension = EditorView.clickAddsSelectionRange.of( + isMultiCursorSelectionActive, + ); const touchSelectionUpdateExtension = EditorView.updateListener.of( (update) => { if (!touchSelectionController) return; @@ -955,6 +974,7 @@ async function EditorManager($header, $body) { themeExtension: themeCompartment.of(getConfiguredThemeExtension()), pointerCursorVisibilityExtension, shiftClickSelectionExtension, + multiCursorSelectionExtension, touchSelectionUpdateExtension, quickToolsModifierInputExtension: quickToolsModifierInput(), searchExtension: search(), @@ -1012,6 +1032,7 @@ async function EditorManager($header, $body) { container: $container, getActiveFile: () => manager?.activeFile || null, isShiftSelectionActive, + isMultiCursorSelectionActive: isQuickToolsMultiCursorSelectionActive, }); // Provide minimal Ace-like API compatibility used by plugins @@ -1520,6 +1541,7 @@ async function EditorManager($header, $body) { themeExtension: themeCompartment.of(getConfiguredThemeExtension()), pointerCursorVisibilityExtension, shiftClickSelectionExtension, + multiCursorSelectionExtension, touchSelectionUpdateExtension, quickToolsModifierInputExtension: quickToolsModifierInput(), searchExtension: search(), diff --git a/src/lib/settings.js b/src/lib/settings.js index 610cc2504..f4b33a718 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -215,7 +215,7 @@ class Settings { }, }, developerMode: false, - shiftClickSelection: false, + shiftClickSelection: true, showShareButton: true, }; this.value = structuredClone(this.#defaultSettings); diff --git a/src/settings/editorSettings.js b/src/settings/editorSettings.js index 572d29b8b..23692727c 100644 --- a/src/settings/editorSettings.js +++ b/src/settings/editorSettings.js @@ -223,13 +223,6 @@ export default function editorSettings() { info: strings["settings-info-editor-fade-fold-widgets"], category: categories.guidesIndicators, }, - { - key: "shiftClickSelection", - text: strings["shift click selection"], - checkbox: values.shiftClickSelection, - info: strings["settings-info-editor-shift-click-selection"], - category: categories.cursorSelection, - }, { key: "showShareButton", text: strings["show share button"], diff --git a/src/test/editor.tests.js b/src/test/editor.tests.js index d7f5eb39a..7a10509d7 100644 --- a/src/test/editor.tests.js +++ b/src/test/editor.tests.js @@ -20,8 +20,14 @@ import { runQuickToolKey, runQuickToolNavigation, } from "cm/quickToolsNavigation"; -import { isShiftSelectionActive } from "cm/shiftSelection"; -import { getEdgeScrollDirections } from "cm/touchSelectionMenu"; +import { + isMultiCursorSelectionActive, + isShiftSelectionActive, +} from "cm/shiftSelection"; +import { + addPointerSelectionRange, + getEdgeScrollDirections, +} from "cm/touchSelectionMenu"; import { TestRunner } from "./tester"; export async function runCodeMirrorTests(writeOutput) { @@ -393,25 +399,112 @@ export async function runCodeMirrorTests(writeOutput) { }, ); - runner.test("Physical Shift pointer selection respects setting", (test) => { + runner.test( + "Physical Shift pointer selection ignores hidden setting", + (test) => { + test.assert( + isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + shiftClickSelection: false, + }), + "Physical Shift should ignore old disabled settings", + ); + test.assert( + isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + shiftClickSelection: true, + }), + "Physical Shift should work when setting is enabled", + ); + test.assert( + isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + }), + "Physical Shift should default to enabled", + ); + }, + ); + + runner.test("Quick tools Ctrl/Meta enable multi-cursor selection", (test) => { test.assert( - !isShiftSelectionActive({ - event: { shiftKey: true }, - quickToolsShift: false, - shiftClickSelection: false, + isMultiCursorSelectionActive({ + event: { ctrlKey: false, metaKey: false }, + quickToolsCtrl: true, + quickToolsMeta: false, }), - "Physical Shift should be ignored when setting is disabled", + "Quick tools Ctrl should add a cursor", ); test.assert( - isShiftSelectionActive({ - event: { shiftKey: true }, - quickToolsShift: false, - shiftClickSelection: true, + isMultiCursorSelectionActive({ + event: { ctrlKey: false, metaKey: false }, + quickToolsCtrl: false, + quickToolsMeta: true, }), - "Physical Shift should work when setting is enabled", + "Quick tools Meta should add a cursor", ); }); + runner.test("Physical multi-cursor modifier follows platform", (test) => { + test.assert( + isMultiCursorSelectionActive({ + event: { ctrlKey: true, metaKey: false }, + isMac: false, + }), + "Ctrl should add a cursor on non-macOS", + ); + test.assert( + !isMultiCursorSelectionActive({ + event: { ctrlKey: false, metaKey: true }, + isMac: false, + }), + "Meta should not be the default add-cursor modifier on non-macOS", + ); + test.assert( + isMultiCursorSelectionActive({ + event: { ctrlKey: false, metaKey: true }, + isMac: true, + }), + "Meta should add a cursor on macOS", + ); + test.assert( + !isMultiCursorSelectionActive({ + event: { ctrlKey: true, metaKey: false }, + isMac: true, + }), + "Ctrl should not be the default add-cursor modifier on macOS", + ); + }); + + runner.test("Pointer multi-cursor appends cursor and range", (test) => { + const cursorSelection = addPointerSelectionRange( + EditorSelection.single(10), + { + anchor: 10, + head: 4, + }, + ); + + test.assertEqual(cursorSelection.ranges.length, 2); + test.assert(cursorSelection.main.empty, "Added cursor should be empty"); + test.assertEqual(cursorSelection.main.head, 4); + + const rangeSelection = addPointerSelectionRange( + EditorSelection.single(10), + { + anchor: 0, + head: 4, + extend: true, + }, + ); + + test.assertEqual(rangeSelection.ranges.length, 2); + test.assertEqual(rangeSelection.main.anchor, 0); + test.assertEqual(rangeSelection.main.head, 4); + }); + runner.test("Quick tools Shift maps printable text", (test) => { test.assertEqual(mapQuickToolShiftText("a"), "A"); test.assertEqual(mapQuickToolShiftText("1"), "!"); diff --git a/src/test/sanity.tests.js b/src/test/sanity.tests.js index 61971c8d9..19eac7b73 100644 --- a/src/test/sanity.tests.js +++ b/src/test/sanity.tests.js @@ -1,3 +1,8 @@ +import { + clearModifierState, + clearQuickToolsButtonFeedback, + removeActionStackEntries, +} from "../handlers/quickToolsState"; import { getLanguageModeRecommendationSearchKeyword } from "../lib/languageModeRecommendations"; import { TestRunner } from "./tester"; @@ -82,6 +87,71 @@ export async function runSanityTests(writeOutput) { ); }); + runner.test("Quick tools modifier cleanup emits inactive state", (test) => { + const state = { + shift: true, + alt: true, + ctrl: true, + meta: true, + }; + const emitted = []; + const events = { + shift: [(value) => emitted.push(["shift", value])], + alt: [(value) => emitted.push(["alt", value])], + ctrl: [(value) => emitted.push(["ctrl", value])], + meta: [(value) => emitted.push(["meta", value])], + }; + + test.assert(clearModifierState(state, events)); + test.assertEqual(state.shift, false); + test.assertEqual(state.alt, false); + test.assertEqual(state.ctrl, false); + test.assertEqual(state.meta, false); + test.assertEqual( + JSON.stringify(emitted), + JSON.stringify([ + ["shift", false], + ["alt", false], + ["ctrl", false], + ["meta", false], + ]), + ); + }); + + runner.test( + "Quick tools feedback cleanup clears stale button state", + (test) => { + const container = document.createElement("div"); + const button = document.createElement("button"); + button.className = "icon active click"; + button.dataset.timeout = setTimeout(() => {}, 1000); + container.append(button); + + test.assertEqual(clearQuickToolsButtonFeedback([container]), 1); + test.assert(!button.classList.contains("active")); + test.assert(!button.classList.contains("click")); + test.assertEqual(button.dataset.timeout, undefined); + }, + ); + + runner.test( + "Quick tools search cleanup removes duplicate stack entries", + (test) => { + const entries = ["search-bar", "other", "search-bar"]; + const stack = { + remove(id) { + const index = entries.indexOf(id); + if (index === -1) return false; + entries.splice(index, 1); + return true; + }, + }; + + test.assertEqual(removeActionStackEntries(stack, "search-bar"), 2); + test.assertEqual(JSON.stringify(entries), JSON.stringify(["other"])); + }, + ); + // Run all tests return await runner.run(writeOutput); } From 32c22ba69e643f4b288031b49b7c8719f28af5f6 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:50:09 +0530 Subject: [PATCH 3/3] fixed stuffs - addressed the greptile review regarding: combo normalization and mutation thing - Fixed non-arrow quick-tool keys leaving modifier state active after dispatch. - Fixed rapid quick-tool taps leaving orphan `.active` feedback on symbol buttons - Restored `shiftClickSelection` in editor settings - Made disabling shiftClickSelection actually block Shift pointer/range selection, including line numbers. - Updated editor tests for the restored Shift selection behavior. - Added old-WebView settings switch fallback --- src/cm/lineNumberSelection.ts | 11 ++++- src/cm/quickToolsModifierKeys.ts | 11 ++--- src/cm/shiftSelection.ts | 2 + src/components/settingsPage.scss | 19 ++++++++ src/handlers/quickTools.js | 18 ++++--- src/handlers/quickToolsInit.js | 9 +++- src/lib/editorManager.js | 34 ++++++++++++- src/settings/editorSettings.js | 7 +++ src/test/editor.tests.js | 83 ++++++++++++++++---------------- 9 files changed, 133 insertions(+), 61 deletions(-) diff --git a/src/cm/lineNumberSelection.ts b/src/cm/lineNumberSelection.ts index f6ae02bdb..e7a262461 100644 --- a/src/cm/lineNumberSelection.ts +++ b/src/cm/lineNumberSelection.ts @@ -14,6 +14,10 @@ type LineNumberClickEvent = Pick< | "defaultPrevented" >; +interface LineNumberClickOptions { + shiftClickSelection?: boolean; +} + function toDocumentOffset( value: number | null | undefined, fallback = 0, @@ -91,6 +95,7 @@ export function handleLineNumberClick( view: EditorView | null | undefined, line: LineInfo, event: LineNumberClickEvent | null | undefined, + options: LineNumberClickOptions = {}, ): boolean { if (!view || !event || event.defaultPrevented) return false; if ((event.button ?? 0) !== 0) return false; @@ -100,13 +105,15 @@ export function handleLineNumberClick( const range = getLineSelectionRange(view.state, line); if (!range) return false; + const extendSelection = + event.shiftKey && options.shiftClickSelection !== false; event.preventDefault(); view.dispatch({ - selection: event.shiftKey + selection: extendSelection ? createExtendedLineSelection(view.state, range) : createLineSelection(range), - userEvent: event.shiftKey ? "select.extend.pointer" : "select.pointer", + userEvent: extendSelection ? "select.extend.pointer" : "select.pointer", }); view.focus(); return true; diff --git a/src/cm/quickToolsModifierKeys.ts b/src/cm/quickToolsModifierKeys.ts index 98d2cbab9..4990fffac 100644 --- a/src/cm/quickToolsModifierKeys.ts +++ b/src/cm/quickToolsModifierKeys.ts @@ -80,13 +80,10 @@ export function getQuickToolCombo( const normalizedKey = normalizeKey(key); if (!normalizedKey) return null; - const parts: string[] = []; - if (modifiers.ctrlKey) parts.push("Ctrl"); - if (modifiers.altKey) parts.push("Alt"); - if (modifiers.shiftKey) parts.push("Shift"); - if (modifiers.metaKey) parts.push("Meta"); - parts.push(normalizedKey); - return normalizeShortcutCombo(parts.join("-")); + const modifierParts = Object.entries(modifiers) + .filter(([, enabled]) => enabled) + .map(([modifier]) => modifier.replace(/Key$/, "")); + return normalizeShortcutCombo([...modifierParts, normalizedKey].join("-")); } export function findQuickToolCommand( diff --git a/src/cm/shiftSelection.ts b/src/cm/shiftSelection.ts index 1f5cc2bad..88f6e8b28 100644 --- a/src/cm/shiftSelection.ts +++ b/src/cm/shiftSelection.ts @@ -14,7 +14,9 @@ interface ShiftSelectionOptions { export function isRangeSelectionActive({ event, quickToolsShift, + shiftClickSelection = true, }: ShiftSelectionOptions = {}): boolean { + if (shiftClickSelection === false) return false; if (quickToolsShift) return true; return !!event?.shiftKey; } diff --git a/src/components/settingsPage.scss b/src/components/settingsPage.scss index 8c8eca15e..bfe722e73 100644 --- a/src/components/settingsPage.scss +++ b/src/components/settingsPage.scss @@ -838,13 +838,31 @@ wc-page.detail-settings-page.formatter-settings-page { wc-page.detail-settings-page { .input-checkbox { .box { + position: relative !important; background: var(--popup-background-color) !important; border: 1px solid var(--secondary-text-color) !important; opacity: 0.65; &::after { + content: ""; + position: absolute; + top: 0.14rem; + left: 0.14rem; + display: block; + width: 1.25rem; + height: 1.25rem; + border-radius: 999px; background: var(--popup-text-color) !important; opacity: 1 !important; + box-shadow: + 0 0 0 1px var(--border-color), + 0 1px 3px rgba(0, 0, 0, 0.22); + transform: translate3d(0, 0, 0); + transition: transform 160ms ease; + } + + .handle { + opacity: 0 !important; } } @@ -855,6 +873,7 @@ wc-page.detail-settings-page.formatter-settings-page { &::after { background: var(--button-text-color) !important; + transform: translate3d(1.12rem, 0, 0); } } } diff --git a/src/handlers/quickTools.js b/src/handlers/quickTools.js index 09654465b..f1cbf27fc 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -259,12 +259,17 @@ export default function actions(action, value) { case "key": { value = Number.parseInt(value, 10); const keyCombination = getKeys({ keyCode: value }); + const shouldResetKeys = value < 37 || value > 40; setInput(); - if (runCodeMirrorQuickToolKey(value, keyCombination)) { + try { + if (runCodeMirrorQuickToolKey(value, keyCombination)) { + return true; + } + getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); return true; + } finally { + if (shouldResetKeys) resetKeys(); } - getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); - return true; } case "search": @@ -372,12 +377,11 @@ function runCodeMirrorQuickToolKey(keyCode, keyCombination) { } export function handleCodeMirrorQuickToolsTextInput(view, text) { - if (!Object.values(state).includes(true)) return; + if (!Object.values(state).includes(true)) return false; if (!view?.state || !view.contentDOM) return false; - if (!text || text.length !== 1) return; + if (!text || text.length !== 1) return false; const keyCombination = getKeys({ key: text }); - input = view.contentDOM; if ( keyCombination.shiftKey && @@ -411,7 +415,7 @@ export function handleCodeMirrorQuickToolsTextInput(view, text) { return true; } - getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); + view.contentDOM.dispatchEvent(KeyboardEvent("keydown", keyCombination)); setQuicktoolsUsed(); return true; } diff --git a/src/handlers/quickToolsInit.js b/src/handlers/quickToolsInit.js index 72152dc4d..951eb4dbc 100644 --- a/src/handlers/quickToolsInit.js +++ b/src/handlers/quickToolsInit.js @@ -27,6 +27,7 @@ let timeout; let $touchstart; function reset() { + clearTouchFeedback(); moveX = 0; movedX = 0; time = 300; @@ -38,6 +39,12 @@ function reset() { active = false; } +function clearTouchFeedback() { + if ($touchstart && !active) { + $touchstart.classList.remove("active"); + } +} + /** * Initialize quick tools * @param {HTMLElement} $footer @@ -312,7 +319,7 @@ function touchcancel(e) { document.removeEventListener("touchmove", touchmove); clearTimeout(timeout); clearTimeout(contextmenuTimeout); - if (!active) $touchstart.classList.remove("active"); + clearTouchFeedback(); } /** diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 0cd29b308..064f33160 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -1,7 +1,13 @@ import sidebarApps from "sidebarApps"; import { indentUnit, language as languageFacet } from "@codemirror/language"; import { search } from "@codemirror/search"; -import { Compartment, EditorState, Prec, StateEffect } from "@codemirror/state"; +import { + Compartment, + EditorSelection, + EditorState, + Prec, + StateEffect, +} from "@codemirror/state"; import { oneDark } from "@codemirror/theme-one-dark"; import { closeHoverTooltips, @@ -219,10 +225,13 @@ async function EditorManager($header, $body) { }); }, ); + const isShiftClickSelectionEnabled = () => + appSettings.value.shiftClickSelection !== false; const isShiftSelectionActive = (event) => { return resolveShiftSelectionActive({ event, quickToolsShift: quickTools?.$footer?.dataset?.shift != null, + shiftClickSelection: isShiftClickSelectionEnabled(), }); }; const isMultiCursorSelectionActive = (event) => { @@ -240,6 +249,24 @@ async function EditorManager($header, $body) { }); }; const shiftClickSelectionExtension = EditorView.domEventHandlers({ + mousedown(event, view) { + if (!event.shiftKey || isShiftClickSelectionEnabled()) return false; + if ((event.button ?? 0) !== 0) return false; + + const pos = view.posAtCoords( + { x: event.clientX, y: event.clientY }, + false, + ); + if (pos != null) { + view.dispatch({ + selection: EditorSelection.cursor(pos), + userEvent: "select.pointer", + }); + view.focus(); + } + event.preventDefault(); + return true; + }, click(event) { if (!touchSelectionController?.consumePendingShiftSelectionClick(event)) { return false; @@ -413,7 +440,10 @@ async function EditorManager($header, $body) { const lineNumberConfig = { domEventHandlers: { click(view, line, event) { - return handleLineNumberClick(view, line, event); + return handleLineNumberClick(view, line, event, { + shiftClickSelection: + appSettings.value.shiftClickSelection !== false, + }); }, }, }; diff --git a/src/settings/editorSettings.js b/src/settings/editorSettings.js index 23692727c..68be59c24 100644 --- a/src/settings/editorSettings.js +++ b/src/settings/editorSettings.js @@ -230,6 +230,13 @@ export default function editorSettings() { info: strings["settings-info-editor-show-share-button"], category: categories.cursorSelection, }, + { + key: "shiftClickSelection", + text: strings["shift click selection"], + checkbox: values.shiftClickSelection !== false, + info: strings["settings-info-editor-shift-click-selection"], + category: categories.cursorSelection, + }, { key: "rtlText", text: strings["line based rtl switching"], diff --git a/src/test/editor.tests.js b/src/test/editor.tests.js index 7a10509d7..c0a3fd216 100644 --- a/src/test/editor.tests.js +++ b/src/test/editor.tests.js @@ -385,48 +385,47 @@ export async function runCodeMirrorTests(writeOutput) { }, ); - runner.test( - "Quick tools Shift enables pointer selection independently", - (test) => { - test.assert( - isShiftSelectionActive({ - event: { shiftKey: false }, - quickToolsShift: true, - shiftClickSelection: false, - }), - "Quick tools Shift should bypass shift-click setting", - ); - }, - ); - - runner.test( - "Physical Shift pointer selection ignores hidden setting", - (test) => { - test.assert( - isShiftSelectionActive({ - event: { shiftKey: true }, - quickToolsShift: false, - shiftClickSelection: false, - }), - "Physical Shift should ignore old disabled settings", - ); - test.assert( - isShiftSelectionActive({ - event: { shiftKey: true }, - quickToolsShift: false, - shiftClickSelection: true, - }), - "Physical Shift should work when setting is enabled", - ); - test.assert( - isShiftSelectionActive({ - event: { shiftKey: true }, - quickToolsShift: false, - }), - "Physical Shift should default to enabled", - ); - }, - ); + runner.test("Shift pointer selection follows setting", (test) => { + test.assert( + isShiftSelectionActive({ + event: { shiftKey: false }, + quickToolsShift: true, + shiftClickSelection: true, + }), + "Quick tools Shift should extend selection when enabled", + ); + test.assert( + !isShiftSelectionActive({ + event: { shiftKey: false }, + quickToolsShift: true, + shiftClickSelection: false, + }), + "Quick tools Shift should respect disabled setting", + ); + test.assert( + !isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + shiftClickSelection: false, + }), + "Physical Shift should respect disabled setting", + ); + test.assert( + isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + shiftClickSelection: true, + }), + "Physical Shift should work when setting is enabled", + ); + test.assert( + isShiftSelectionActive({ + event: { shiftKey: true }, + quickToolsShift: false, + }), + "Physical Shift should default to enabled", + ); + }); runner.test("Quick tools Ctrl/Meta enable multi-cursor selection", (test) => { test.assert(