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/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index d91c0a2e9..e445bf9b4 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -8,7 +8,9 @@ interface MainEditorExtensionOptions { themeExtension?: Extension; pointerCursorVisibilityExtension?: Extension; shiftClickSelectionExtension?: Extension; + multiCursorSelectionExtension?: Extension; touchSelectionUpdateExtension?: Extension; + quickToolsModifierInputExtension?: Extension; searchExtension?: Extension; readOnlyExtension?: Extension; optionExtensions?: Extension[]; @@ -46,7 +48,9 @@ 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); 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..4990fffac --- /dev/null +++ b/src/cm/quickToolsModifierKeys.ts @@ -0,0 +1,152 @@ +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 modifierParts = Object.entries(modifiers) + .filter(([, enabled]) => enabled) + .map(([modifier]) => modifier.replace(/Key$/, "")); + return normalizeShortcutCombo([...modifierParts, normalizedKey].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..88f6e8b28 --- /dev/null +++ b/src/cm/shiftSelection.ts @@ -0,0 +1,42 @@ +interface ShiftSelectionOptions { + event?: { + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + }; + quickToolsShift?: boolean; + quickToolsCtrl?: boolean; + quickToolsMeta?: boolean; + shiftClickSelection?: boolean; + isMac?: boolean; +} + +export function isRangeSelectionActive({ + event, + quickToolsShift, + shiftClickSelection = true, +}: ShiftSelectionOptions = {}): boolean { + if (shiftClickSelection === false) return false; + if (quickToolsShift) return true; + 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/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 af6c39350..f1cbf27fc 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -7,13 +7,24 @@ 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"; 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; @@ -37,6 +48,8 @@ const events = { meta: [], }; +setQuickToolsModifierInputHandler(handleCodeMirrorQuickToolsTextInput); + /** * @typedef { 'shift' | 'alt' | 'ctrl' | 'meta' } QuickToolsEvent * @typedef {(value: boolean)=>void} QuickToolsEventListener @@ -194,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 @@ -212,7 +231,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,14 +258,18 @@ export default function actions(action, value) { case "key": { value = Number.parseInt(value, 10); - if (value < 37 || value > 40) { - resetKeys(); - } + const keyCombination = getKeys({ keyCode: value }); + const shouldResetKeys = value < 37 || value > 40; setInput(); - getInput().dispatchEvent( - KeyboardEvent("keydown", getKeys({ keyCode: value })), - ); - return true; + try { + if (runCodeMirrorQuickToolKey(value, keyCombination)) { + return true; + } + getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); + return true; + } finally { + if (shouldResetKeys) resetKeys(); + } } case "search": @@ -337,6 +364,62 @@ 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 false; + if (!view?.state || !view.contentDOM) return false; + if (!text || text.length !== 1) return false; + + const keyCombination = getKeys({ key: text }); + + 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; + } + + view.contentDOM.dispatchEvent(KeyboardEvent("keydown", keyCombination)); + setQuicktoolsUsed(); + return true; +} + function toggleSearch() { const $footer = quickTools.$footer; const $searchRow1 = quickTools.$searchRow1; @@ -347,6 +430,8 @@ function toggleSearch() { const selectedText = getSelectedText(editor); if (!$footer.contains($searchRow1)) { + removeSearchBarActions(); + clearSearchQuickToolsState(); const { className } = quickTools.$toggler; const $content = [...$footer.children]; const footerHeight = getFooterHeight(); @@ -354,6 +439,7 @@ function toggleSearch() { $toggler.className = "floating icon clearclose"; $footer.content = [$searchRow1, $searchRow2]; + clearSearchQuickToolsState($content); setRefValue($searchInput, selectedText || ""); $searchInput.oninput = function () { @@ -383,10 +469,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; }, }); @@ -398,7 +487,7 @@ function toggleSearch() { return; } - actionStack.get("search-bar").action(); + actionStack.get("search-bar")?.action?.(); } $searchInput.focus(); @@ -441,6 +530,7 @@ function setHeight(height = 1, save = true) { if (height === 0) { searchBar.action(); } else { + clearSearchQuickToolsState(activeSearchState?.content); const footerHeight = Number(height) || 0; activeSearchState = { className: @@ -448,6 +538,7 @@ function setHeight(height = 1, save = true) { content: getQuickToolsRows(footerHeight), footerHeight, }; + clearQuickToolsButtonFeedback(activeSearchState.content); if (save) { appSettings.update({ quickTools: height }, false); } @@ -495,9 +586,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(); @@ -644,15 +737,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"); } /** @@ -708,50 +815,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/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/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 3f96314b0..19ddd22f6 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, @@ -67,8 +73,13 @@ 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 { + 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"; @@ -214,11 +225,48 @@ async function EditorManager($header, $body) { }); }, ); + const isShiftClickSelectionEnabled = () => + appSettings.value.shiftClickSelection !== false; 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: isShiftClickSelectionEnabled(), + }); + }; + 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({ + 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) return false; + + view.dispatch({ + selection: EditorSelection.cursor(pos), + userEvent: "select.pointer", + }); + view.focus(); + event.preventDefault(); + return true; + }, click(event) { if (!touchSelectionController?.consumePendingShiftSelectionClick(event)) { return false; @@ -227,6 +275,9 @@ async function EditorManager($header, $body) { return true; }, }); + const multiCursorSelectionExtension = EditorView.clickAddsSelectionRange.of( + isMultiCursorSelectionActive, + ); const touchSelectionUpdateExtension = EditorView.updateListener.of( (update) => { if (!touchSelectionController) return; @@ -389,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, + }); }, }, }; @@ -950,7 +1004,9 @@ async function EditorManager($header, $body) { themeExtension: themeCompartment.of(getConfiguredThemeExtension()), pointerCursorVisibilityExtension, shiftClickSelectionExtension, + multiCursorSelectionExtension, touchSelectionUpdateExtension, + quickToolsModifierInputExtension: quickToolsModifierInput(), searchExtension: search(), // Ensure read-only can be toggled later via compartment readOnlyExtension: readOnlyCompartment.of(EditorState.readOnly.of(false)), @@ -1006,6 +1062,7 @@ async function EditorManager($header, $body) { container: $container, getActiveFile: () => manager?.activeFile || null, isShiftSelectionActive, + isMultiCursorSelectionActive: isQuickToolsMultiCursorSelectionActive, }); // Provide minimal Ace-like API compatibility used by plugins @@ -1514,7 +1571,9 @@ async function EditorManager($header, $body) { themeExtension: themeCompartment.of(getConfiguredThemeExtension()), pointerCursorVisibilityExtension, shiftClickSelectionExtension, + multiCursorSelectionExtension, touchSelectionUpdateExtension, + quickToolsModifierInputExtension: quickToolsModifierInput(), searchExtension: search(), // Keep dynamic compartments across state swaps optionExtensions: getBaseExtensionsFromOptions(), 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..68be59c24 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"], @@ -237,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 b039524be..c0a3fd216 100644 --- a/src/test/editor.tests.js +++ b/src/test/editor.tests.js @@ -10,7 +10,24 @@ import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import createBaseExtensions from "cm/baseExtensions"; import indentGuides from "cm/indentGuides"; -import { getEdgeScrollDirections } from "cm/touchSelectionMenu"; +import { + findQuickToolCommand, + getShortcutAlternatives, + mapQuickToolShiftText, +} from "cm/quickToolsModifierKeys"; +import { + createQuickToolKeyEvent, + runQuickToolKey, + runQuickToolNavigation, +} from "cm/quickToolsNavigation"; +import { + isMultiCursorSelectionActive, + isShiftSelectionActive, +} from "cm/shiftSelection"; +import { + addPointerSelectionRange, + getEdgeScrollDirections, +} from "cm/touchSelectionMenu"; import { TestRunner } from "./tester"; export async function runCodeMirrorTests(writeOutput) { @@ -220,6 +237,304 @@ 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("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( + isMultiCursorSelectionActive({ + event: { ctrlKey: false, metaKey: false }, + quickToolsCtrl: true, + quickToolsMeta: false, + }), + "Quick tools Ctrl should add a cursor", + ); + test.assert( + isMultiCursorSelectionActive({ + event: { ctrlKey: false, metaKey: false }, + quickToolsCtrl: false, + quickToolsMeta: true, + }), + "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"), "!"); + 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 // ========================================= diff --git a/src/test/sanity.tests.js b/src/test/sanity.tests.js index 22802918c..8fc86fca5 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 { isVersionGreater } from "../utils/version"; import { TestRunner } from "./tester"; @@ -83,6 +88,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"])); + }, + ); + runner.test( "Plugin version comparison only accepts newer versions", (test) => {