From b388172e0bddedede93d80f23270f49a408afad5 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Fri, 14 Aug 2026 15:21:05 +0530 Subject: [PATCH] fix: read-only mode --- src/cm/commandRegistry.js | 3 +- src/cm/editorReadOnly.ts | 187 ++++++++++++++++++++++ src/cm/lineNumberSelection.ts | 3 +- src/cm/lsp/codeActions.ts | 5 +- src/cm/lsp/documentSymbols.ts | 3 +- src/cm/quickToolsModifierInput.ts | 38 ++++- src/cm/quickToolsNavigation.ts | 5 +- src/cm/selectionMenuUtils.ts | 25 +++ src/cm/touchSelectionMenu.js | 154 +++++++++++++++--- src/components/palette/index.js | 3 +- src/components/referencesPanel/utils.js | 3 +- src/components/symbolsPanel/index.js | 3 +- src/handlers/editorFileTab.js | 3 +- src/handlers/quickTools.js | 186 ++++++++++++++++------ src/handlers/quickToolsInit.js | 6 +- src/lib/commands.js | 3 +- src/lib/editorFile.js | 18 ++- src/lib/editorManager.js | 142 ++++++++++++++--- src/lib/selectionMenu.js | 3 +- src/pages/problems/problems.js | 3 +- src/palettes/commandPalette/index.js | 3 +- src/res/file-icons/style.css | 9 +- tests/unit/editorReadOnly.test.ts | 202 ++++++++++++++++++++++++ tests/unit/fileIcons.test.js | 29 ++++ tests/unit/quickToolsReadOnly.test.ts | 114 +++++++++++++ tests/unit/selectionMenuUtils.test.ts | 43 +++++ 26 files changed, 1077 insertions(+), 119 deletions(-) create mode 100644 src/cm/editorReadOnly.ts create mode 100644 src/cm/selectionMenuUtils.ts create mode 100644 tests/unit/editorReadOnly.test.ts create mode 100644 tests/unit/fileIcons.test.js create mode 100644 tests/unit/quickToolsReadOnly.test.ts create mode 100644 tests/unit/selectionMenuUtils.test.ts diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index a4577f7cd5..1446027cbb 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -73,6 +73,7 @@ import { } from "@codemirror/lsp-client"; import { Compartment, EditorSelection } from "@codemirror/state"; import { keymap } from "@codemirror/view"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import { copyLineDownFoldAware, copyLineUpFoldAware, @@ -175,7 +176,7 @@ function registerCoreCommands() { requiresView: false, run(view) { const resolvedView = resolveView(view); - resolvedView?.focus(); + if (resolvedView) focusEditorIfEditable(resolvedView); return true; }, }); diff --git a/src/cm/editorReadOnly.ts b/src/cm/editorReadOnly.ts new file mode 100644 index 0000000000..c5e94acd45 --- /dev/null +++ b/src/cm/editorReadOnly.ts @@ -0,0 +1,187 @@ +import { + Compartment, + EditorSelection, + EditorState, + Transaction, + type Extension, + type SelectionRange, +} from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; + +interface PointerTapSnapshot { + pointerId: number; + x: number; + y: number; + timeStamp: number; + isPrimary?: boolean; + button?: number; + cancelled?: boolean; +} + +interface PointerTapEnd { + pointerId: number; + x: number; + y: number; + timeStamp: number; + isPrimary?: boolean; + button?: number; +} + +interface PointerTapOptions { + maxDelay?: number; + maxDistance?: number; +} + +const readOnlyUserChangePattern = /^(?:input|delete|move|undo|redo)(?:\.|$)/; + +const readOnlyInputGuard = EditorView.inputHandler.of((view) => { + return view.state.readOnly; +}); + +const readOnlyUserChangeFilter = EditorState.transactionFilter.of( + (transaction) => { + if (!transaction.startState.readOnly || !transaction.docChanged) { + return transaction; + } + + const userEvent = transaction.annotation(Transaction.userEvent); + return isReadOnlyUserChange(userEvent) ? [] : transaction; + }, +); + +const readOnlyFocusGuard = EditorView.domEventHandlers({ + focus(event, view) { + event.preventDefault(); + blurEditorIfReadOnly(view, view.state.readOnly); + return true; + }, + beforeinput(event, view) { + if (!view.state.readOnly) return false; + event.preventDefault(); + blurEditorIfReadOnly(view, true); + return true; + }, +}); + +/** + * Keep CodeMirror's document and DOM editability in sync. + */ +export function createEditorReadOnlyExtension(readOnly: boolean): Extension { + return [ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ...(readOnly + ? [readOnlyFocusGuard, readOnlyInputGuard, readOnlyUserChangeFilter] + : []), + ]; +} + +/** Identify document-changing transactions produced by user interaction. */ +export function isReadOnlyUserChange(userEvent: string | undefined): boolean { + return !!userEvent && readOnlyUserChangePattern.test(userEvent); +} + +/** Dismiss an active soft keyboard when an editor becomes read-only. */ +export function blurEditorIfReadOnly( + view: EditorView, + readOnly: boolean, +): void { + if (!readOnly) return; + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLElement && + view.dom.contains(activeElement) + ) { + activeElement.blur(); + } + view.contentDOM.blur(); +} + +/** Atomically update all read-only behavior controlled by the compartment. */ +export function reconfigureEditorReadOnly( + view: EditorView, + compartment: Compartment, + readOnly: boolean, +): void { + view.dispatch({ + effects: compartment.reconfigure(createEditorReadOnlyExtension(readOnly)), + }); + blurEditorIfReadOnly(view, readOnly); +} + +/** Focus editable editors and actively settle read-only editors unfocused. */ +export function focusEditorIfEditable(view: EditorView): boolean { + if (view.state.readOnly) { + blurEditorIfReadOnly(view, true); + return false; + } + view.focus(); + return true; +} + +/** Classify a completed pointer gesture as a short primary tap. */ +export function shouldCommitReadOnlyTap( + start: PointerTapSnapshot, + end: PointerTapEnd, + options: PointerTapOptions = {}, +): boolean { + const { maxDelay = 500, maxDistance = 20 } = options; + if (start.cancelled) return false; + if (start.pointerId !== end.pointerId) return false; + if (start.isPrimary === false || end.isPrimary === false) return false; + if ((start.button ?? 0) !== 0 || (end.button ?? 0) !== 0) return false; + const duration = end.timeStamp - start.timeStamp; + if (!Number.isFinite(duration) || duration < 0 || duration > maxDelay) { + return false; + } + return Math.hypot(end.x - start.x, end.y - start.y) <= maxDistance; +} + +/** Collapse an existing read-only selection without focusing or editing. */ +export function collapseReadOnlySelection( + view: EditorView, + pos: number, +): boolean { + if (!view.state.readOnly || view.state.selection.main.empty) return false; + const position = Math.max(0, Math.min(pos, view.state.doc.length)); + view.dispatch({ + selection: EditorSelection.cursor(position), + userEvent: "select.pointer", + }); + blurEditorIfReadOnly(view, true); + return true; +} + +/** Resolve the text selected by a read-only long-press. */ +export function resolveReadOnlyContextSelection( + state: EditorState, + pos: number, +): SelectionRange { + const docLength = state.doc.length; + const position = Math.max( + 0, + Math.min(Number.isFinite(pos) ? pos : state.selection.main.head, docLength), + ); + const current = state.selection.main; + + if (!current.empty && position >= current.from && position <= current.to) { + return current; + } + + const word = state.wordAt(position); + if (word) return word; + if (docLength === 0) return EditorSelection.cursor(0); + + let from = Math.min(position, docLength - 1); + const unit = state.doc.sliceString(from, from + 1).charCodeAt(0); + if (unit >= 0xdc00 && unit <= 0xdfff && from > 0) { + const previousUnit = state.doc.sliceString(from - 1, from).charCodeAt(0); + if (previousUnit >= 0xd800 && previousUnit <= 0xdbff) from -= 1; + } + + const codePoint = state.doc + .sliceString(from, Math.min(from + 2, docLength)) + .codePointAt(0); + const width = codePoint != null && codePoint > 0xffff ? 2 : 1; + return EditorSelection.range(from, Math.min(from + width, docLength)); +} diff --git a/src/cm/lineNumberSelection.ts b/src/cm/lineNumberSelection.ts index e7a2624610..6b05a1af73 100644 --- a/src/cm/lineNumberSelection.ts +++ b/src/cm/lineNumberSelection.ts @@ -1,5 +1,6 @@ import { EditorSelection } from "@codemirror/state"; import type { BlockInfo, EditorView } from "@codemirror/view"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; type LineInfo = Pick | null | undefined; @@ -115,7 +116,7 @@ export function handleLineNumberClick( : createLineSelection(range), userEvent: extendSelection ? "select.extend.pointer" : "select.pointer", }); - view.focus(); + focusEditorIfEditable(view); return true; } diff --git a/src/cm/lsp/codeActions.ts b/src/cm/lsp/codeActions.ts index 7022e5f1e1..89811ac560 100644 --- a/src/cm/lsp/codeActions.ts +++ b/src/cm/lsp/codeActions.ts @@ -1,5 +1,6 @@ import { LSPPlugin } from "@codemirror/lsp-client"; import { EditorView } from "@codemirror/view"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import toast from "components/toast"; import select from "dialogs/select"; import type { @@ -409,7 +410,7 @@ export async function showCodeActionsMenu(view: EditorView): Promise { const index = Number.parseInt(String(result), 10); if (!Number.isNaN(index) && index >= 0 && index < items.length) { await executeCodeAction(view, items[index]); - view.focus(); + focusEditorIfEditable(view); return true; } } @@ -417,7 +418,7 @@ export async function showCodeActionsMenu(view: EditorView): Promise { // User cancelled selection } - view.focus(); + focusEditorIfEditable(view); return false; } diff --git a/src/cm/lsp/documentSymbols.ts b/src/cm/lsp/documentSymbols.ts index 6d973f1175..942ac13355 100644 --- a/src/cm/lsp/documentSymbols.ts +++ b/src/cm/lsp/documentSymbols.ts @@ -6,6 +6,7 @@ import { LSPPlugin } from "@codemirror/lsp-client"; import type { EditorView } from "@codemirror/view"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import type { DocumentSymbol, Position, @@ -302,7 +303,7 @@ export async function navigateToSymbol( scrollIntoView: true, }); - view.focus(); + focusEditorIfEditable(view); return true; } catch (error) { console.warn("Failed to navigate to symbol:", error); diff --git a/src/cm/quickToolsModifierInput.ts b/src/cm/quickToolsModifierInput.ts index 52792a50cb..741cd80e83 100644 --- a/src/cm/quickToolsModifierInput.ts +++ b/src/cm/quickToolsModifierInput.ts @@ -1,5 +1,9 @@ import type { Extension } from "@codemirror/state"; -import { EditorView, type EditorView as CodeMirrorEditorView } from "@codemirror/view"; +import { + EditorView, + type EditorView as CodeMirrorEditorView, +} from "@codemirror/view"; +import { blurEditorIfReadOnly, focusEditorIfEditable } from "cm/editorReadOnly"; type QuickToolsModifierInputHandler = ( view: CodeMirrorEditorView, @@ -14,8 +18,38 @@ export function setQuickToolsModifierInputHandler( handleTextInput = typeof handler === "function" ? handler : () => false; } +export function canQuickToolsEdit(view: CodeMirrorEditorView): boolean { + return !view.state.readOnly; +} + +/** Use the capture input for read-only shortcuts without focusing the editor. */ +export function focusQuickToolsModifierInput( + view: CodeMirrorEditorView, + captureInput: HTMLElement, +): boolean { + if (!view.state.readOnly) { + focusEditorIfEditable(view); + return false; + } + blurEditorIfReadOnly(view, true); + captureInput.focus(); + return true; +} + +/** Close a read-only shortcut capture without disturbing intentional UI focus. */ +export function finishQuickToolsModifierInput( + view: CodeMirrorEditorView, + captureInput: HTMLElement, +): boolean { + if (!view.state.readOnly) return false; + captureInput.blur(); + blurEditorIfReadOnly(view, true); + return true; +} + export default function quickToolsModifierInput(): Extension { return EditorView.inputHandler.of((view, _from, _to, text) => { - return !!handleTextInput(view, text); + const handled = !!handleTextInput(view, text); + return view.state.readOnly || handled; }); } diff --git a/src/cm/quickToolsNavigation.ts b/src/cm/quickToolsNavigation.ts index 9159268572..bc3d8e6c23 100644 --- a/src/cm/quickToolsNavigation.ts +++ b/src/cm/quickToolsNavigation.ts @@ -35,6 +35,7 @@ import { type EditorView as CodeMirrorEditorView, runScopeHandlers, } from "@codemirror/view"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import createKeyboardEvent from "utils/keyboardEvent"; interface QuickToolKeyModifiers { @@ -146,7 +147,7 @@ export function runQuickToolKey( const event = createQuickToolKeyEvent(keyCode, modifiers); if (runScopeHandlers(view, event, "editor")) { - view.focus(); + focusEditorIfEditable(view); return true; } @@ -154,7 +155,7 @@ export function runQuickToolKey( if (!command) return false; const handled = command(view); if (handled !== false) { - view.focus(); + focusEditorIfEditable(view); return true; } diff --git a/src/cm/selectionMenuUtils.ts b/src/cm/selectionMenuUtils.ts new file mode 100644 index 0000000000..414dbfa836 --- /dev/null +++ b/src/cm/selectionMenuUtils.ts @@ -0,0 +1,25 @@ +export interface SelectionMenuItem { + mode?: "selected" | "all"; + readOnly?: boolean; +} + +export interface SelectionMenuFilterOptions { + readOnly: boolean; + hasSelection: boolean; +} + +/** Filter selection actions using Acode's read-only and selection rules. */ +export function filterSelectionMenuItems( + items: readonly T[], + options: SelectionMenuFilterOptions, +): T[] { + const { readOnly, hasSelection } = options; + return items.filter((item) => { + if (readOnly && !item.readOnly) return false; + if (hasSelection && !["selected", "all"].includes(item.mode ?? "all")) { + return false; + } + if (!hasSelection && item.mode === "selected") return false; + return true; + }); +} diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index 4b8767f69a..3e98096573 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -1,6 +1,15 @@ import { EditorSelection } from "@codemirror/state"; +import { + collapseReadOnlySelection, + focusEditorIfEditable, + resolveReadOnlyContextSelection, + shouldCommitReadOnlyTap, +} from "cm/editorReadOnly"; +import { filterSelectionMenuItems } from "cm/selectionMenuUtils"; import selectionMenu from "lib/selectionMenu"; +export { filterSelectionMenuItems } from "cm/selectionMenuUtils"; + const TAP_MAX_DELAY = 500; const TAP_MAX_DISTANCE = 20; const EDGE_SCROLL_GAP = 40; @@ -73,21 +82,6 @@ export function clampMenuPosition(menuRect, containerRect) { }; } -/** - * Filter menu items using Ace-compatible rules. - * @param {ReturnType} items - * @param {{readOnly:boolean,hasSelection:boolean}} options - */ -export function filterSelectionMenuItems(items, options) { - const { readOnly, hasSelection } = options; - return items.filter((item) => { - if (readOnly && !item.readOnly) return false; - if (hasSelection && !["selected", "all"].includes(item.mode)) return false; - if (!hasSelection && item.mode === "selected") return false; - return true; - }); -} - /** * Detect which edge(s) should trigger drag auto-scroll. * @param {{ @@ -147,6 +141,7 @@ class TouchSelectionMenuController { #isScrolling = false; #isPointerInteracting = false; #pointerSelectionSession = null; + #readOnlyTapSession = null; #pendingPointerSelectionClick = null; #menuActive = false; #menuRequested = false; @@ -173,6 +168,7 @@ class TouchSelectionMenuController { const root = this.#view.dom; root.addEventListener("contextmenu", this.#onContextMenu, true); document.addEventListener("pointerdown", this.#onGlobalPointerDown, true); + document.addEventListener("pointermove", this.#onGlobalPointerMove, true); document.addEventListener("pointerup", this.#onGlobalPointerUp, true); document.addEventListener("pointercancel", this.#onGlobalPointerUp, true); @@ -204,6 +200,11 @@ class TouchSelectionMenuController { true, ); document.removeEventListener("pointerup", this.#onGlobalPointerUp, true); + document.removeEventListener( + "pointermove", + this.#onGlobalPointerMove, + true, + ); document.removeEventListener( "pointercancel", this.#onGlobalPointerUp, @@ -213,6 +214,7 @@ class TouchSelectionMenuController { cancelAnimationFrame(this.#stateSyncRaf); this.#stateSyncRaf = 0; this.#pointerSelectionSession = null; + this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; this.#tooltipObserver?.disconnect(); this.#hideMenu(true); @@ -222,6 +224,7 @@ class TouchSelectionMenuController { this.#enabled = !!enabled; if (this.#enabled) return; this.#pointerSelectionSession = null; + this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; @@ -263,6 +266,7 @@ class TouchSelectionMenuController { if (this.#isScrolling) return; this.#clearMenuShowTimer(); this.#isScrolling = true; + this.#cancelReadOnlyTap(); this.#hideMenu(); } @@ -274,6 +278,7 @@ class TouchSelectionMenuController { onStateChanged(meta = {}) { if (!this.#enabled) return; + if (meta.selectionChanged) this.#cancelReadOnlyTap(); if (this.#handlingMenuAction) return; if (!this.#shouldShowMenu()) { if (!this.#hasSelection()) { @@ -291,6 +296,7 @@ class TouchSelectionMenuController { onSessionChanged() { if (!this.#enabled) return; this.#pointerSelectionSession = null; + this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; this.#menuRequested = false; this.#isPointerInteracting = false; @@ -302,6 +308,17 @@ class TouchSelectionMenuController { #onContextMenu = (event) => { if (!this.#enabled) return; if (this.#isIgnoredPointerTarget(event.target)) return; + this.#cancelReadOnlyTap(); + if (this.#isReadOnly()) { + const pos = this.#safePosAtCoords(event.clientX, event.clientY); + if (pos != null) { + const range = resolveReadOnlyContextSelection(this.#view.state, pos); + this.#view.dispatch({ + selection: EditorSelection.create([range]), + userEvent: "select.pointer", + }); + } + } event.preventDefault(); event.stopPropagation(); this.#menuRequested = true; @@ -310,28 +327,47 @@ class TouchSelectionMenuController { #onGlobalPointerDown = (event) => { const target = event.target; - if (this.$menu.contains(target)) return; + if (this.$menu.contains(target)) { + this.#readOnlyTapSession = null; + return; + } if (this.#isIgnoredPointerTarget(target)) { this.#pointerSelectionSession = null; + this.#readOnlyTapSession = null; return; } if (target instanceof Node && this.#view.dom.contains(target)) { this.#capturePointerSelection(event); + this.#captureReadOnlyTap(event); this.#isPointerInteracting = true; this.#clearMenuShowTimer(); return; } this.#pointerSelectionSession = null; + this.#readOnlyTapSession = null; this.#isPointerInteracting = false; this.#menuRequested = false; this.#hideMenu(); }; + #onGlobalPointerMove = (event) => { + const session = this.#readOnlyTapSession; + if (!session || session.pointerId !== event.pointerId) return; + if ( + Math.hypot(event.clientX - session.x, event.clientY - session.y) > + TAP_MAX_DISTANCE + ) { + this.#cancelReadOnlyTap(); + } + }; + #onGlobalPointerUp = (event) => { if (event.type === "pointerup") { this.#commitPointerSelection(event); + this.#commitReadOnlyTap(event); } else { this.#pointerSelectionSession = null; + this.#readOnlyTapSession = null; } if (!this.#isPointerInteracting) return; this.#isPointerInteracting = false; @@ -346,6 +382,78 @@ class TouchSelectionMenuController { this.#hideMenu(); }; + #captureReadOnlyTap(event) { + this.#readOnlyTapSession = null; + if (!this.#enabled || !this.#isReadOnly() || !this.#hasSelection()) { + return; + } + if (!(event.isTrusted && event.isPrimary)) return; + if (typeof event.button === "number" && event.button !== 0) return; + if (this.#canExtendSelection(event) || this.#canAddSelectionRange(event)) { + return; + } + + this.#readOnlyTapSession = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + timeStamp: event.timeStamp, + isPrimary: event.isPrimary, + button: event.button, + selection: this.#view.state.selection, + cancelled: false, + }; + } + + #cancelReadOnlyTap() { + if (this.#readOnlyTapSession) { + this.#readOnlyTapSession.cancelled = true; + } + } + + #commitReadOnlyTap(event) { + const session = this.#readOnlyTapSession; + this.#readOnlyTapSession = null; + if (!session || !this.#enabled || !this.#isReadOnly()) return false; + if (!this.#view.state.selection.eq(session.selection)) return false; + if ( + !shouldCommitReadOnlyTap( + session, + { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + timeStamp: event.timeStamp, + isPrimary: event.isPrimary, + button: event.button, + }, + { maxDelay: TAP_MAX_DELAY, maxDistance: TAP_MAX_DISTANCE }, + ) + ) { + return false; + } + const target = event.target; + if (!(target instanceof Node) || !this.#view.dom.contains(target)) { + return false; + } + if (this.#isIgnoredPointerTarget(target)) return false; + + const pos = this.#safePosAtCoords(event.clientX, event.clientY); + if (pos == null) return false; + if (!collapseReadOnlySelection(this.#view, pos)) return false; + this.#menuRequested = false; + this.#clearMenuShowTimer(); + this.#hideMenu(true); + try { + document.getSelection()?.removeAllRanges(); + } catch (error) { + console.warn("Failed to clear native read-only selection.", error); + } + event.preventDefault(); + focusEditorIfEditable(this.#view); + return true; + } + #capturePointerSelection(event) { if (!this.#canHandlePointerSelection(event)) { this.#pointerSelectionSession = null; @@ -441,8 +549,8 @@ class TouchSelectionMenuController { } #shouldShowMenu() { - if (this.#isScrolling || this.#isPointerInteracting || !this.#view.hasFocus) - return false; + if (this.#isScrolling || this.#isPointerInteracting) return false; + if (!this.#view.hasFocus && !this.#isReadOnly()) return false; return this.#hasSelection() || this.#menuRequested; } @@ -475,6 +583,14 @@ class TouchSelectionMenuController { } } + #safePosAtCoords(x, y) { + try { + return this.#view.posAtCoords({ x, y }, false); + } catch { + return null; + } + } + #getMenuAnchor(selection = this.#hasSelection()) { const range = this.#view.state.selection.main; if (!selection) { @@ -535,7 +651,7 @@ class TouchSelectionMenuController { this.#handlingMenuAction = false; this.#menuRequested = false; this.#hideMenu(); - this.#view.focus(); + focusEditorIfEditable(this.#view); } }; $item.addEventListener("pointerdown", runAction); diff --git a/src/components/palette/index.js b/src/components/palette/index.js index fe68530699..52d06a08db 100644 --- a/src/components/palette/index.js +++ b/src/components/palette/index.js @@ -1,4 +1,5 @@ import "./style.scss"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import inputhints from "components/inputhints"; import keyboardHandler from "handlers/keyboard"; import actionStack from "lib/actionStack"; @@ -179,7 +180,7 @@ export default function palette( if (!isChained) { const { activeFile, editor } = editorManager; if (activeFile.wasFocused) { - editor.focus(); + focusEditorIfEditable(editor); } } diff --git a/src/components/referencesPanel/utils.js b/src/components/referencesPanel/utils.js index 828cd0d377..d88a9ca84f 100644 --- a/src/components/referencesPanel/utils.js +++ b/src/components/referencesPanel/utils.js @@ -1,4 +1,5 @@ import { EditorView } from "@codemirror/view"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import Sidebar from "components/sidebar"; import DOMPurify from "dompurify"; import openFile from "lib/openFile"; @@ -128,7 +129,7 @@ export async function navigateToReference(ref) { selection: { anchor: from, head: to }, effects: EditorView.scrollIntoView(from, { y: "center" }), }); - editor.focus(); + focusEditorIfEditable(editor); } catch (error) { console.error("Failed to navigate to reference:", error); } diff --git a/src/components/symbolsPanel/index.js b/src/components/symbolsPanel/index.js index 5265f09b3f..987f80c3ca 100644 --- a/src/components/symbolsPanel/index.js +++ b/src/components/symbolsPanel/index.js @@ -1,4 +1,5 @@ import "./styles.scss"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import { fetchDocumentSymbols, navigateToSymbol } from "cm/lsp"; import actionStack from "lib/actionStack"; @@ -417,7 +418,7 @@ function createSymbolsPanel() { } if (state.editorView) { - state.editorView.focus(); + focusEditorIfEditable(state.editorView); } } diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index ffbf9f629b..77ea649ea4 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -1,3 +1,4 @@ +import { focusEditorIfEditable } from "cm/editorReadOnly"; import config from "lib/config"; import settings from "lib/settings"; import { animate } from "motion"; @@ -96,7 +97,7 @@ export default function startDrag(e) { const { editor, activeFile } = editorManager; if (activeFile.focusedBefore) { - editor.focus(); + focusEditorIfEditable(editor); } if (settings.value.vibrateOnTap) { diff --git a/src/handlers/quickTools.js b/src/handlers/quickTools.js index aff8f9009e..4c56e91f86 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -8,7 +8,13 @@ import { setSearchQuery, } from "@codemirror/search"; import { executeCommand, getRegisteredCommands } from "cm/commandRegistry"; -import { setQuickToolsModifierInputHandler } from "cm/quickToolsModifierInput"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; +import { + canQuickToolsEdit, + finishQuickToolsModifierInput, + focusQuickToolsModifierInput, + setQuickToolsModifierInputHandler, +} from "cm/quickToolsModifierInput"; import { findQuickToolCommand, mapQuickToolShiftText, @@ -62,6 +68,16 @@ quickTools.$input.addEventListener("input", (e) => { quickTools.$input.value = ""; if (!key || key.length > 1) return; const keyCombination = getKeys({ key }); + const target = getInput(); + + if (isCodeMirrorEditorInput(target)) { + handleCodeMirrorQuickToolsTextInput( + editorManager.editor, + key, + keyCombination, + ); + return; + } if ( keyCombination.shiftKey && @@ -75,7 +91,7 @@ quickTools.$input.addEventListener("input", (e) => { } resetKeys(); - getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); + target.dispatchEvent(KeyboardEvent("keydown", keyCombination)); setQuicktoolsUsed(); }); @@ -92,6 +108,15 @@ quickTools.$input.addEventListener("keydown", (e) => { e.preventDefault(); let target = getInput(); + if (isCodeMirrorEditorInput(target)) { + try { + runCodeMirrorQuickToolKey(keyCode, keyCombination); + } finally { + resetKeys(); + dismissReadOnlyQuickToolsInput(editorManager.editor); + } + return; + } if (target === quickTools.$input) { target = editorManager.editor.contentDOM; } @@ -121,8 +146,8 @@ function setupHistoryNavigation() { $searchInput.el.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") { e.preventDefault(); - const { editor, activeFile } = editorManager; - editor.focus(); + const { editor } = editorManager; + focusEditorIfEditable(editor); actionStack.get("search-bar")?.action(); } else if (e.key === "ArrowUp") { e.preventDefault(); @@ -211,7 +236,15 @@ export const key = { export function clearQuickToolsModifierState({ restoreFocus = false } = {}) { const changed = clearModifierState(state, events); - if (restoreFocus) input?.focus?.(); + if (restoreFocus) restoreQuickToolsTargetFocus(); + return changed; +} + +export function cancelQuickToolsModifierInput() { + const changed = clearQuickToolsModifierState(); + quickTools.$input.value = ""; + quickTools.$input.blur(); + dismissReadOnlyQuickToolsInput(editorManager.editor); return changed; } @@ -234,14 +267,12 @@ export default function actions(action, value) { events[action].forEach((cb) => cb(value)); if (Object.values(state).includes(true)) { if (isCodeMirrorEditorInput(input)) { - editor?.focus(); + focusQuickToolsModifierInput(editor, $input); } else { $input.focus(); } - } else if (input) { - input.focus(); } else { - $input.blur(); + restoreQuickToolsTargetFocus(); } return value; @@ -255,7 +286,11 @@ export default function actions(action, value) { const commandName = typeof value === "string" ? value : String(value ?? ""); if (!commandName) return false; - return executeCommand(commandName, editor); + try { + return executeCommand(commandName, editor); + } finally { + if (editor?.state?.readOnly) cancelQuickToolsModifierInput(); + } } case "key": { @@ -267,10 +302,12 @@ export default function actions(action, value) { if (runCodeMirrorQuickToolKey(value, keyCombination)) { return true; } + if (isReadOnlyCodeMirrorInput(input)) return true; getInput().dispatchEvent(KeyboardEvent("keydown", keyCombination)); return true; } finally { if (shouldResetKeys) resetKeys(); + dismissReadOnlyQuickToolsInput(editor); } } @@ -309,6 +346,7 @@ export default function actions(action, value) { return true; case "search-replace": + if (blockReadOnlyQuickToolsEdit(editor)) return false; if ($replaceInput.value) { searchHistory.addToHistory($replaceInput.value); } @@ -327,6 +365,7 @@ export default function actions(action, value) { return true; case "search-replace-all": + if (blockReadOnlyQuickToolsEdit(editor)) return false; if ($replaceInput.value) { searchHistory.addToHistory($replaceInput.value); } @@ -360,9 +399,15 @@ function setInput() { if ( !activeElement || activeElement === quickTools.$input || - activeElement === document.body - ) + activeElement === document.body || + quickTools.$footer.contains(activeElement) + ) { + const { activeFile, editor } = editorManager; + if (activeFile?.type === "editor" && editor?.state?.readOnly) { + input = editor.contentDOM; + } return; + } input = activeElement; } @@ -378,48 +423,68 @@ function runCodeMirrorQuickToolKey(keyCode, keyCombination) { return runQuickToolKey(editorManager.editor, keyCode, keyCombination); } -export function handleCodeMirrorQuickToolsTextInput(view, text) { - if (!Object.values(state).includes(true)) return false; +export function handleCodeMirrorQuickToolsTextInput( + view, + text, + modifiers = null, +) { if (!view?.state || !view.contentDOM) return false; if (!text || text.length !== 1) return false; - const keyCombination = getKeys({ key: text }); + const keyCombination = modifiers || getKeys({ key: text }); + if (!hasQuickToolsModifier(keyCombination)) return false; + const canEdit = canQuickToolsEdit(view); + resetKeys(); - if ( - keyCombination.shiftKey && - !keyCombination.ctrlKey && - !keyCombination.altKey && - !keyCombination.metaKey - ) { - resetKeys(); - view.dispatch(view.state.replaceSelection(mapQuickToolShiftText(text))); - setQuicktoolsUsed(); - return true; - } + try { + if ( + keyCombination.shiftKey && + !keyCombination.ctrlKey && + !keyCombination.altKey && + !keyCombination.metaKey + ) { + if (canEdit) { + view.dispatch({ + ...view.state.replaceSelection(mapQuickToolShiftText(text)), + userEvent: "input.quicktools", + }); + } + return true; + } - if ( - !keyCombination.ctrlKey && - !keyCombination.altKey && - !keyCombination.metaKey - ) { - return false; - } + if ( + !keyCombination.ctrlKey && + !keyCombination.altKey && + !keyCombination.metaKey + ) { + return false; + } - resetKeys(); + const command = findQuickToolCommand( + getRegisteredCommands(), + text, + keyCombination, + ); + if (command && executeCommand(command.name, view)) { + return true; + } - const command = findQuickToolCommand( - getRegisteredCommands(), - text, - keyCombination, - ); - if (command && executeCommand(command.name, view)) { - setQuicktoolsUsed(); + if (!canEdit) return true; + view.contentDOM.dispatchEvent(KeyboardEvent("keydown", keyCombination)); return true; + } finally { + setQuicktoolsUsed(); + dismissReadOnlyQuickToolsInput(view); } +} - view.contentDOM.dispatchEvent(KeyboardEvent("keydown", keyCombination)); - setQuicktoolsUsed(); - return true; +function hasQuickToolsModifier(modifiers) { + return !!( + modifiers.shiftKey || + modifiers.ctrlKey || + modifiers.altKey || + modifiers.metaKey + ); } function toggleSearch() { @@ -766,7 +831,7 @@ function focusEditor() { } if (editor) { - editor.focus(); + focusEditorIfEditable(editor); } } @@ -845,9 +910,40 @@ function insertText(value) { } const { editor } = editorManager; + if (blockReadOnlyQuickToolsEdit(editor)) return false; return editor ? editor.insert(text) : false; } +function blockReadOnlyQuickToolsEdit(view) { + if (!view?.state?.readOnly) return false; + cancelQuickToolsModifierInput(); + return true; +} + +function isReadOnlyCodeMirrorInput(target) { + const { editor } = editorManager; + return isCodeMirrorEditorInput(target) && !!editor?.state?.readOnly; +} + +function dismissReadOnlyQuickToolsInput(view) { + if (!view?.state?.readOnly) return false; + return finishQuickToolsModifierInput(view, quickTools.$input); +} + +function restoreQuickToolsTargetFocus() { + const { editor } = editorManager; + if (isCodeMirrorEditorInput(input) && editor) { + if (dismissReadOnlyQuickToolsInput(editor)) return; + focusEditorIfEditable(editor); + return; + } + if (input) { + input.focus?.(); + } else { + quickTools.$input.blur(); + } +} + function shiftKeyMapping(char) { return mapQuickToolShiftText(char); } diff --git a/src/handlers/quickToolsInit.js b/src/handlers/quickToolsInit.js index 811f49043f..330cd7d197 100644 --- a/src/handlers/quickToolsInit.js +++ b/src/handlers/quickToolsInit.js @@ -1,10 +1,11 @@ import { redoDepth, undoDepth } from "@codemirror/commands"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import quickTools from "components/quickTools"; import { description } from "components/quickTools/items"; import { hideTooltip, showTooltip } from "components/tooltip"; import config from "lib/config"; import appSettings from "lib/settings"; -import actions, { key } from "./quickTools"; +import actions, { cancelQuickToolsModifierInput, key } from "./quickTools"; const CONTEXT_MENU_TIMEOUT = 500; const MOVE_X_THRESHOLD = 50; @@ -101,6 +102,7 @@ export default function init() { }); editorManager.on("editor-state-changed", updateHistoryButtons); + editorManager.on("switch-file", cancelQuickToolsModifierInput); appSettings.on("update:quicktoolsItems:after", () => { setTimeout(updateHistoryButtons, 100); @@ -360,7 +362,7 @@ function oncontextmenu(e) { }; if (activeFile.focused) { - editor.focus(); + focusEditorIfEditable(editor); } dispatchEventWithTimeout(); } diff --git a/src/lib/commands.js b/src/lib/commands.js index 9c20cf0385..ac3ed5dd69 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -1,5 +1,6 @@ import fsOperation from "fileSystem"; import { selectAll } from "@codemirror/commands"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import Sidebar from "components/sidebar"; import confirm from "dialogs/confirm"; import prompt from "dialogs/prompt"; @@ -565,7 +566,7 @@ export default { ); res = await color(defaultColor, () => { if (wasFocused) { - editor.focus(); + focusEditorIfEditable(editor); } }); } catch (_) { diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index b151fda805..4971014344 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -1,6 +1,10 @@ import fsOperation from "fileSystem"; // CodeMirror imports for document state management import { EditorState } from "@codemirror/state"; +import { + focusEditorIfEditable, + reconfigureEditorReadOnly, +} from "cm/editorReadOnly"; import { clearSelection, getDocText, @@ -1419,11 +1423,13 @@ export default class EditorFile { : editorManager.activeFile?.id === this.id ? editorManager.editor : null; - targetEditor?.dispatch({ - effects: readOnlyCompartment.reconfigure( - EditorState.readOnly.of(readOnly), - ), - }); + if (targetEditor) { + reconfigureEditorReadOnly( + targetEditor, + readOnlyCompartment, + readOnly, + ); + } } } catch (error) { console.warn( @@ -1512,7 +1518,7 @@ export default class EditorFile { if (this.type === "editor") { editorManager.container.style.display = "block"; if (this.focused && editorHadDomFocus && !isTouchDevice()) { - editor.focus(); + focusEditorIfEditable(editor); } else { editor.contentDOM.blur(); // Ensure any native DOM selection is cleared on blur to avoid sticky selection handles diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index b58231e0ea..3e812a4d38 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -38,6 +38,12 @@ import { registerExternalCommand, removeExternalCommand, } from "cm/commandRegistry"; +import { + blurEditorIfReadOnly, + createEditorReadOnlyExtension, + focusEditorIfEditable, + reconfigureEditorReadOnly, +} from "cm/editorReadOnly"; import { handleLineNumberClick } from "cm/lineNumberSelection"; import localWordCompletions, { localWordCompletionSource, @@ -864,7 +870,7 @@ async function EditorManager($header, $body) { selection: EditorSelection.cursor(pos), userEvent: "select.pointer", }); - view.focus(); + focusEditorIfEditable(view); event.preventDefault(); return true; }, @@ -1662,7 +1668,7 @@ async function EditorManager($header, $body) { searchExtension: search(), // Ensure read-only can be toggled later via compartment readOnlyExtension: readOnlyCompartment.of( - EditorState.readOnly.of(false), + createEditorReadOnlyExtension(false), ), // Editor options driven by settings via compartments optionExtensions: getBaseExtensionsFromOptions(), @@ -1848,7 +1854,7 @@ async function EditorManager($header, $body) { selection: { anchor: pos, head: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }), }); - editor.focus(); + focusEditorIfEditable(editor); return true; } catch (error) { console.error("Error in gotoLine:", error); @@ -2178,7 +2184,7 @@ async function EditorManager($header, $body) { selection: { anchor: pos, head: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }), }); - targetEditor.focus(); + focusEditorIfEditable(targetEditor); return true; } catch (error) { console.error("Error in gotoLine:", error); @@ -2815,9 +2821,7 @@ async function EditorManager($header, $body) { } try { const ro = !file.editable || !!file.loading; - targetEditor.dispatch({ - effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(ro)), - }); + reconfigureEditorReadOnly(targetEditor, readOnlyCompartment, ro); file.session = targetEditor.state; } catch (error) { warnRecoverable( @@ -2836,12 +2840,12 @@ async function EditorManager($header, $body) { ...getBaseExtensionsFromOptions(), languageCompartment.of([]), lspCompartment.of([]), - readOnlyCompartment.of(EditorState.readOnly.of(true)), - EditorView.editable.of(false), + readOnlyCompartment.of(createEditorReadOnlyExtension(true)), placeholder(`Loading ${file.filename || "file"}...`), ], }); editor.setState(loadingState); + blurEditorIfReadOnly(editor, true); touchSelectionController?.onSessionChanged(); } @@ -2971,7 +2975,7 @@ async function EditorManager($header, $body) { // Apply read-only state based on file.editable/loading using Compartment try { const ro = !file.editable || !!file.loading; - exts.push(readOnlyCompartment.of(EditorState.readOnly.of(ro))); + exts.push(readOnlyCompartment.of(createEditorReadOnlyExtension(ro))); } catch (e) { // safe to ignore; editor will remain editable by default } @@ -3680,9 +3684,7 @@ async function EditorManager($header, $body) { if (file?.type !== "editor") return; try { const ro = !file.editable || !!file.loading; - editor.dispatch({ - effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(ro)), - }); + reconfigureEditorReadOnly(editor, readOnlyCompartment, ro); touchSelectionController?.onStateChanged(); } catch (error) { warnRecoverable( @@ -4267,6 +4269,56 @@ async function EditorManager($header, $body) { // Attach native DOM event listeners directly to the editor's contentDOM const contentDOM = editor.contentDOM; + let readOnlyNativeContextActive = false; + let readOnlyNativeContextResetTimer = null; + const isReadOnlyEditor = () => { + const activeFile = pane.activeFile; + return ( + editor.state.readOnly || + activeFile?.editable === false || + !!activeFile?.loading + ); + }; + const clearFileFocusState = () => { + const activeFile = pane.activeFile; + if (!activeFile) return; + activeFile.focused = false; + activeFile.focusedBefore = false; + }; + const clearPendingKeyboardHideBlur = () => { + if (!pendingKeyboardHideBlur) return; + keyboardHandler.off("keyboardHide", pendingKeyboardHideBlur); + pendingKeyboardHideBlur = null; + }; + const releaseReadOnlyNativeContext = () => { + clearTimeout(readOnlyNativeContextResetTimer); + readOnlyNativeContextResetTimer = null; + if (!readOnlyNativeContextActive) return; + readOnlyNativeContextActive = false; + setNativeContextMenuDisabled(false); + }; + const scheduleReadOnlyNativeContextRelease = () => { + if (!readOnlyNativeContextActive) return; + clearTimeout(readOnlyNativeContextResetTimer); + readOnlyNativeContextResetTimer = setTimeout( + releaseReadOnlyNativeContext, + 800, + ); + }; + function handleContentPointerDown() { + if (!isReadOnlyEditor()) return; + clearTimeout(readOnlyNativeContextResetTimer); + readOnlyNativeContextResetTimer = null; + readOnlyNativeContextActive = true; + setNativeContextMenuDisabled(true); + } + function handleDocumentPointerDown(event) { + if (!readOnlyNativeContextActive) return; + if (event.target instanceof Node && editor.dom.contains(event.target)) { + return; + } + releaseReadOnlyNativeContext(); + } const isFocused = contentDOM === document.activeElement || contentDOM.contains(document.activeElement); @@ -4274,6 +4326,16 @@ async function EditorManager($header, $body) { function handleContentFocus(_event) { setActivePane(pane); + if (isReadOnlyEditor()) { + if (!readOnlyNativeContextActive) { + setNativeContextMenuDisabled(false); + } + clearPendingKeyboardHideBlur(); + clearFileFocusState(); + blurEditorIfReadOnly(editor, true); + touchSelectionController?.onStateChanged(); + return; + } setNativeContextMenuDisabled(true); const activeFile = pane.activeFile; if (activeFile) { @@ -4283,17 +4345,18 @@ async function EditorManager($header, $body) { } async function handleContentBlur(_event) { - setNativeContextMenuDisabled(false); + if (!readOnlyNativeContextActive) { + setNativeContextMenuDisabled(false); + } touchSelectionController?.setMenu(false); + if (isReadOnlyEditor()) { + clearPendingKeyboardHideBlur(); + clearFileFocusState(); + return; + } const { hardKeyboardHidden, keyboardHeight } = await getSystemConfiguration(); - const blur = () => { - const activeFile = pane.activeFile; - if (activeFile) { - activeFile.focused = false; - activeFile.focusedBefore = false; - } - }; + const blur = clearFileFocusState; if ( hardKeyboardHidden === HARDKEYBOARDHIDDEN_NO && keyboardHeight < 100 @@ -4310,9 +4373,7 @@ async function EditorManager($header, $body) { } blur(); }; - if (pendingKeyboardHideBlur) { - keyboardHandler.off("keyboardHide", pendingKeyboardHideBlur); - } + clearPendingKeyboardHideBlur(); pendingKeyboardHideBlur = onKeyboardHide; keyboardHandler.on("keyboardHide", onKeyboardHide); } @@ -4326,6 +4387,18 @@ async function EditorManager($header, $body) { contentDOM.addEventListener("focus", handleContentFocus); contentDOM.addEventListener("blur", handleContentBlur); contentDOM.addEventListener("keydown", handleContentKeydown); + contentDOM.addEventListener("pointerdown", handleContentPointerDown, true); + document.addEventListener("pointerdown", handleDocumentPointerDown, true); + document.addEventListener( + "pointerup", + scheduleReadOnlyNativeContextRelease, + true, + ); + document.addEventListener( + "pointercancel", + scheduleReadOnlyNativeContextRelease, + true, + ); pane.cleanupEditorListeners = () => { scroller?.removeEventListener("scroll", handleEditorScroll); @@ -4335,6 +4408,27 @@ async function EditorManager($header, $body) { contentDOM.removeEventListener("focus", handleContentFocus); contentDOM.removeEventListener("blur", handleContentBlur); contentDOM.removeEventListener("keydown", handleContentKeydown); + contentDOM.removeEventListener( + "pointerdown", + handleContentPointerDown, + true, + ); + document.removeEventListener( + "pointerdown", + handleDocumentPointerDown, + true, + ); + document.removeEventListener( + "pointerup", + scheduleReadOnlyNativeContextRelease, + true, + ); + document.removeEventListener( + "pointercancel", + scheduleReadOnlyNativeContextRelease, + true, + ); + releaseReadOnlyNativeContext(); clearTimeout(scrollTimeout); if (scrollSyncRaf) { cancelAnimationFrame(scrollSyncRaf); diff --git a/src/lib/selectionMenu.js b/src/lib/selectionMenu.js index b54dc193d0..e697802e6c 100644 --- a/src/lib/selectionMenu.js +++ b/src/lib/selectionMenu.js @@ -1,3 +1,4 @@ +import { focusEditorIfEditable } from "cm/editorReadOnly"; import appSettings from "lib/settings"; const exec = (command) => { @@ -9,7 +10,7 @@ const exec = (command) => { editor.setSelection(true); editor.setMenu(true); } - editor.focus(); + focusEditorIfEditable(editor); }; const showCodeActions = async () => { diff --git a/src/pages/problems/problems.js b/src/pages/problems/problems.js index a450cf0d6a..3cf07b4b60 100644 --- a/src/pages/problems/problems.js +++ b/src/pages/problems/problems.js @@ -1,4 +1,5 @@ import "./style.scss"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import { getLspDiagnostics } from "cm/lsp/diagnostics"; import Page from "components/page"; import actionStack from "lib/actionStack"; @@ -82,7 +83,7 @@ export default function Problems() { $page.hide(); setTimeout(() => { - editorManager.editor.focus(); + focusEditorIfEditable(editorManager.editor); }, 100); } } diff --git a/src/palettes/commandPalette/index.js b/src/palettes/commandPalette/index.js index 92ae750f50..7d4efe443e 100644 --- a/src/palettes/commandPalette/index.js +++ b/src/palettes/commandPalette/index.js @@ -1,4 +1,5 @@ import { executeCommand, getRegisteredCommands } from "cm/commandRegistry"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import palette from "components/palette"; import helpers from "utils/helpers"; @@ -8,7 +9,7 @@ export default async function commandPalette() { const wasFocused = editor?.hasFocus ?? false; palette(generateHints, onselect, strings["type command"], () => { - if (wasFocused) editor?.focus(); + if (wasFocused && editor) focusEditorIfEditable(editor); }); function generateHints() { diff --git a/src/res/file-icons/style.css b/src/res/file-icons/style.css index 2d9e4eaa19..0b527ec4c1 100644 --- a/src/res/file-icons/style.css +++ b/src/res/file-icons/style.css @@ -1565,12 +1565,9 @@ color: #78dc50; } -.file_type_typescript:before { - content: "\e98a"; - color: #007acc; -} - -.file_type_ts:before { +.file_type_typescript:before, +.file_type_ts:before, +.file_type_tsx:before { content: "\e98a"; color: #007acc; } diff --git a/tests/unit/editorReadOnly.test.ts b/tests/unit/editorReadOnly.test.ts new file mode 100644 index 0000000000..64f9524aa8 --- /dev/null +++ b/tests/unit/editorReadOnly.test.ts @@ -0,0 +1,202 @@ +// @vitest-environment happy-dom + +import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + collapseReadOnlySelection, + createEditorReadOnlyExtension, + focusEditorIfEditable, + isReadOnlyUserChange, + reconfigureEditorReadOnly, + resolveReadOnlyContextSelection, + shouldCommitReadOnlyTap, +} from "cm/editorReadOnly"; +import { afterEach, describe, expect, it } from "vitest"; + +const views: EditorView[] = []; + +afterEach(() => { + while (views.length) views.pop()?.destroy(); + document.body.replaceChildren(); +}); + +function createEditor(readOnly = false) { + const compartment = new Compartment(); + const state = EditorState.create({ + doc: "read only content", + extensions: [compartment.of(createEditorReadOnlyExtension(readOnly))], + }); + const view = new EditorView({ state, parent: document.body }); + views.push(view); + return { compartment, view }; +} + +function expectEditable(view: EditorView) { + expect(view.state.readOnly).toBe(false); + expect(view.state.facet(EditorView.editable)).toBe(true); + expect(view.contentDOM.getAttribute("contenteditable")).toBe("true"); + expect(view.contentDOM.hasAttribute("aria-readonly")).toBe(false); + expect(view.contentDOM.hasAttribute("tabindex")).toBe(false); +} + +function expectReadOnly(view: EditorView) { + expect(view.state.readOnly).toBe(true); + expect(view.state.facet(EditorView.editable)).toBe(false); + expect(view.contentDOM.getAttribute("contenteditable")).toBe("false"); + expect(view.contentDOM.getAttribute("aria-readonly")).toBe("true"); + expect(view.contentDOM.hasAttribute("tabindex")).toBe(false); +} + +describe("editor read-only configuration", () => { + it("keeps an editable editor focusable for text input", () => { + const { view } = createEditor(); + + expectEditable(view); + view.focus(); + expect(view.hasFocus).toBe(true); + }); + + it("uses a non-editable DOM while preserving state selection", () => { + const { view } = createEditor(true); + const originalDocument = view.state.doc.toString(); + + expectReadOnly(view); + view.dispatch({ selection: EditorSelection.range(0, 4) }); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(4); + expect(view.state.doc.toString()).toBe(originalDocument); + }); + + it("blocks user document changes but permits internal synchronization", () => { + const { view } = createEditor(true); + view.dispatch({ selection: EditorSelection.range(0, 4) }); + view.dispatch({ + changes: { from: 0, to: 4, insert: "EDIT" }, + userEvent: "input.quicktools", + }); + expect(view.state.doc.toString()).toBe("read only content"); + expect([ + view.state.selection.main.from, + view.state.selection.main.to, + ]).toEqual([0, 4]); + + view.dispatch({ changes: { from: 0, to: 4, insert: "SYNC" } }); + expect(view.state.doc.toString()).toBe("SYNC only content"); + }); + + it("classifies only editing user events as read-only mutations", () => { + expect(isReadOnlyUserChange("input.quicktools")).toBe(true); + expect(isReadOnlyUserChange("delete.backward")).toBe(true); + expect(isReadOnlyUserChange("undo")).toBe(true); + expect(isReadOnlyUserChange("select.pointer")).toBe(false); + expect(isReadOnlyUserChange(undefined)).toBe(false); + }); + + it("rejects later focus attempts and never restores read-only focus", () => { + const { view } = createEditor(true); + view.focus(); + expect(view.hasFocus).toBe(false); + focusEditorIfEditable(view); + expect(view.hasFocus).toBe(false); + }); + + it("blurs on entry and fully restores editable mode", () => { + const { compartment, view } = createEditor(); + view.focus(); + expect(view.hasFocus).toBe(true); + + reconfigureEditorReadOnly(view, compartment, true); + expectReadOnly(view); + expect(view.hasFocus).toBe(false); + + reconfigureEditorReadOnly(view, compartment, false); + expectEditable(view); + view.focus(); + expect(view.hasFocus).toBe(true); + }); + + it("restores focus after selection actions only for editable files", () => { + const readOnly = createEditor(true).view; + const editable = createEditor(false).view; + + focusEditorIfEditable(readOnly); + expect(readOnly.hasFocus).toBe(false); + focusEditorIfEditable(editable); + expect(editable.hasFocus).toBe(true); + }); + + it("collapses a read-only selection without editing or focusing", () => { + const { view } = createEditor(true); + view.dispatch({ selection: EditorSelection.range(0, 4) }); + const originalDocument = view.state.doc.toString(); + + expect(collapseReadOnlySelection(view, 7)).toBe(true); + expect(view.state.selection.main.empty).toBe(true); + expect(view.state.selection.main.head).toBe(7); + expect(view.state.doc.toString()).toBe(originalDocument); + expect(view.hasFocus).toBe(false); + expect(collapseReadOnlySelection(view, 2)).toBe(false); + }); + + it("accepts only uncancelled short primary taps", () => { + const start = { + pointerId: 1, + x: 10, + y: 20, + timeStamp: 100, + isPrimary: true, + button: 0, + }; + const end = { + pointerId: 1, + x: 14, + y: 24, + timeStamp: 180, + isPrimary: true, + button: 0, + }; + + expect(shouldCommitReadOnlyTap(start, end)).toBe(true); + expect(shouldCommitReadOnlyTap({ ...start, cancelled: true }, end)).toBe( + false, + ); + expect(shouldCommitReadOnlyTap(start, { ...end, x: 50 })).toBe(false); + expect(shouldCommitReadOnlyTap(start, { ...end, timeStamp: 700 })).toBe( + false, + ); + expect(shouldCommitReadOnlyTap(start, { ...end, pointerId: 2 })).toBe( + false, + ); + expect(shouldCommitReadOnlyTap(start, { ...end, isPrimary: false })).toBe( + false, + ); + }); + + it("resolves words and preserves an existing long-press selection", () => { + let state = EditorState.create({ + doc: "alpha beta", + selection: EditorSelection.range(0, 5), + }); + let range = resolveReadOnlyContextSelection(state, 3); + expect([range.from, range.to]).toEqual([0, 5]); + + state = EditorState.create({ doc: "alpha beta" }); + range = resolveReadOnlyContextSelection(state, 7); + expect([range.from, range.to]).toEqual([6, 10]); + }); + + it("uses character and empty-document fallbacks for long-press", () => { + let state = EditorState.create({ doc: "alpha + beta" }); + let range = resolveReadOnlyContextSelection(state, 6); + expect(state.sliceDoc(range.from, range.to)).toBe("+"); + + state = EditorState.create({ doc: "alpha 💡 beta" }); + range = resolveReadOnlyContextSelection(state, 6); + expect(state.sliceDoc(range.from, range.to)).toBe("💡"); + + state = EditorState.create({ doc: "" }); + range = resolveReadOnlyContextSelection(state, 0); + expect(range.empty).toBe(true); + expect(range.from).toBe(0); + }); +}); diff --git a/tests/unit/fileIcons.test.js b/tests/unit/fileIcons.test.js new file mode 100644 index 0000000000..87f05de24c --- /dev/null +++ b/tests/unit/fileIcons.test.js @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const stylesheetPath = fileURLToPath( + new URL("../../src/res/file-icons/style.css", import.meta.url), +); +const stylesheet = fs.readFileSync(stylesheetPath, "utf8"); + +function getDeclarations(selector) { + for (const match of stylesheet.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const selectors = match[1].split(",").map((value) => value.trim()); + if (selectors.includes(selector)) return match[2].trim(); + } + return null; +} + +describe("TypeScript file icons", () => { + it("uses the TypeScript glyph and color for TSX files", () => { + const typescript = getDeclarations(".file_type_typescript:before"); + const ts = getDeclarations(".file_type_ts:before"); + const tsx = getDeclarations(".file_type_tsx:before"); + + expect(typescript).toContain('content: "\\e98a"'); + expect(typescript).toContain("color: #007acc"); + expect(ts).toBe(typescript); + expect(tsx).toBe(typescript); + }); +}); diff --git a/tests/unit/quickToolsReadOnly.test.ts b/tests/unit/quickToolsReadOnly.test.ts new file mode 100644 index 0000000000..5a89cb861c --- /dev/null +++ b/tests/unit/quickToolsReadOnly.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom + +import { selectAll } from "@codemirror/commands"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; +import { createEditorReadOnlyExtension } from "cm/editorReadOnly"; +import quickToolsModifierInput, { + canQuickToolsEdit, + finishQuickToolsModifierInput, + focusQuickToolsModifierInput, + setQuickToolsModifierInputHandler, +} from "cm/quickToolsModifierInput"; +import { runQuickToolKey } from "cm/quickToolsNavigation"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("utils/keyboardEvent", () => ({ + default: (type: string, init: KeyboardEventInit) => + new KeyboardEvent(type, init), +})); + +const views: EditorView[] = []; + +afterEach(() => { + setQuickToolsModifierInputHandler(() => false); + while (views.length) views.pop()?.destroy(); + document.body.replaceChildren(); +}); + +function createEditor(readOnly: boolean, extensions = []) { + const state = EditorState.create({ + doc: "alpha beta", + extensions: [createEditorReadOnlyExtension(readOnly), ...extensions], + }); + const view = new EditorView({ state, parent: document.body }); + views.push(view); + return view; +} + +describe("read-only QuickTools interaction", () => { + it("selects all without focusing or changing the document", () => { + const view = createEditor(true, [ + keymap.of([{ key: "Ctrl-a", run: selectAll }]), + ]); + const originalDocument = view.state.doc.toString(); + + expect(runQuickToolKey(view, 65, { ctrlKey: true })).toBe(true); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(view.state.doc.length); + expect(view.state.doc.toString()).toBe(originalDocument); + expect(view.hasFocus).toBe(false); + }); + + it("allows selection navigation but blocks deletion", () => { + const view = createEditor(true); + view.dispatch({ selection: EditorSelection.cursor(0) }); + + expect(runQuickToolKey(view, 39, { shiftKey: true })).toBe(true); + expect(view.state.selection.main.to).toBe(1); + const originalDocument = view.state.doc.toString(); + expect(runQuickToolKey(view, 8)).toBe(false); + expect(view.state.doc.toString()).toBe(originalDocument); + expect(view.hasFocus).toBe(false); + }); + + it("uses and dismisses the capture input for read-only shortcuts", () => { + const view = createEditor(true); + const captureInput = document.createElement("input"); + document.body.append(captureInput); + + expect(focusQuickToolsModifierInput(view, captureInput)).toBe(true); + expect(document.activeElement).toBe(captureInput); + expect(view.hasFocus).toBe(false); + expect(finishQuickToolsModifierInput(view, captureInput)).toBe(true); + expect(document.activeElement).not.toBe(captureInput); + expect(view.hasFocus).toBe(false); + }); + + it("preserves ordinary editable focus behavior", () => { + const view = createEditor(false); + const captureInput = document.createElement("input"); + document.body.append(captureInput); + + expect(canQuickToolsEdit(view)).toBe(true); + expect(focusQuickToolsModifierInput(view, captureInput)).toBe(false); + expect(view.hasFocus).toBe(true); + expect(finishQuickToolsModifierInput(view, captureInput)).toBe(false); + expect(view.hasFocus).toBe(true); + }); + + it("consumes leaked text input in read-only mode", () => { + const handler = vi.fn(() => false); + setQuickToolsModifierInputHandler(handler); + const view = createEditor(true, [quickToolsModifierInput()]); + const inputHandlers = view.state.facet(EditorView.inputHandler); + const quickToolsHandler = inputHandlers.at(-1); + + expect(quickToolsHandler?.(view, 0, 0, "X", () => null)).toBe(true); + expect(handler).toHaveBeenCalledWith(view, "X"); + expect(view.state.doc.toString()).toBe("alpha beta"); + }); + + it("the user-change filter prevents selected text replacement", () => { + const view = createEditor(true); + view.dispatch({ selection: EditorSelection.range(0, 5) }); + view.dispatch({ + ...view.state.replaceSelection("X"), + userEvent: "input.quicktools", + }); + + expect(view.state.doc.toString()).toBe("alpha beta"); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(5); + }); +}); diff --git a/tests/unit/selectionMenuUtils.test.ts b/tests/unit/selectionMenuUtils.test.ts new file mode 100644 index 0000000000..962f912e94 --- /dev/null +++ b/tests/unit/selectionMenuUtils.test.ts @@ -0,0 +1,43 @@ +import { filterSelectionMenuItems } from "cm/selectionMenuUtils"; +import { describe, expect, it } from "vitest"; + +const items = [ + { id: "copy", mode: "selected", readOnly: true }, + { id: "cut", mode: "selected", readOnly: false }, + { id: "paste", mode: "all", readOnly: false }, + { id: "select-all", mode: "all", readOnly: true }, +] as const; + +describe("selection menu filtering", () => { + it("keeps only read-only actions that apply to a selection", () => { + const visible = filterSelectionMenuItems(items, { + readOnly: true, + hasSelection: true, + }); + + expect(visible.map((item) => item.id)).toEqual(["copy", "select-all"]); + }); + + it("hides selection-only actions when no text is selected", () => { + const visible = filterSelectionMenuItems(items, { + readOnly: true, + hasSelection: false, + }); + + expect(visible.map((item) => item.id)).toEqual(["select-all"]); + }); + + it("keeps editable actions under the existing mode rules", () => { + const visible = filterSelectionMenuItems(items, { + readOnly: false, + hasSelection: true, + }); + + expect(visible.map((item) => item.id)).toEqual([ + "copy", + "cut", + "paste", + "select-all", + ]); + }); +});