Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cm/commandRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -175,7 +176,7 @@ function registerCoreCommands() {
requiresView: false,
run(view) {
const resolvedView = resolveView(view);
resolvedView?.focus();
if (resolvedView) focusEditorIfEditable(resolvedView);
return true;
},
});
Expand Down
187 changes: 187 additions & 0 deletions src/cm/editorReadOnly.ts
Original file line number Diff line number Diff line change
@@ -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));
}
3 changes: 2 additions & 1 deletion src/cm/lineNumberSelection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EditorSelection } from "@codemirror/state";
import type { BlockInfo, EditorView } from "@codemirror/view";
import { focusEditorIfEditable } from "cm/editorReadOnly";

type LineInfo = Pick<BlockInfo, "from" | "to"> | null | undefined;

Expand Down Expand Up @@ -115,7 +116,7 @@ export function handleLineNumberClick(
: createLineSelection(range),
userEvent: extendSelection ? "select.extend.pointer" : "select.pointer",
});
view.focus();
focusEditorIfEditable(view);
return true;
}

Expand Down
5 changes: 3 additions & 2 deletions src/cm/lsp/codeActions.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -409,15 +410,15 @@ export async function showCodeActionsMenu(view: EditorView): Promise<boolean> {
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;
}
}
} catch {
// User cancelled selection
}

view.focus();
focusEditorIfEditable(view);
return false;
}

Expand Down
3 changes: 2 additions & 1 deletion src/cm/lsp/documentSymbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 36 additions & 2 deletions src/cm/quickToolsModifierInput.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
});
}
5 changes: 3 additions & 2 deletions src/cm/quickToolsNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -146,15 +147,15 @@ export function runQuickToolKey(

const event = createQuickToolKeyEvent(keyCode, modifiers);
if (runScopeHandlers(view, event, "editor")) {
view.focus();
focusEditorIfEditable(view);
return true;
}

const command = getFallbackCommand(keyCode, modifiers);
if (!command) return false;
const handled = command(view);
if (handled !== false) {
view.focus();
focusEditorIfEditable(view);
return true;
}

Expand Down
25 changes: 25 additions & 0 deletions src/cm/selectionMenuUtils.ts
Original file line number Diff line number Diff line change
@@ -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<T extends SelectionMenuItem>(
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;
});
}
Loading