Skip to content

Commit e5fa7d5

Browse files
deadlyjackAjit Kumar
andauthored
fix: read-only mode (#2740)
Co-authored-by: Ajit Kumar <dellevenjack@gmail>
1 parent baa6f8e commit e5fa7d5

26 files changed

Lines changed: 1077 additions & 119 deletions

src/cm/commandRegistry.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
} from "@codemirror/lsp-client";
7474
import { Compartment, EditorSelection } from "@codemirror/state";
7575
import { keymap } from "@codemirror/view";
76+
import { focusEditorIfEditable } from "cm/editorReadOnly";
7677
import {
7778
copyLineDownFoldAware,
7879
copyLineUpFoldAware,
@@ -175,7 +176,7 @@ function registerCoreCommands() {
175176
requiresView: false,
176177
run(view) {
177178
const resolvedView = resolveView(view);
178-
resolvedView?.focus();
179+
if (resolvedView) focusEditorIfEditable(resolvedView);
179180
return true;
180181
},
181182
});

src/cm/editorReadOnly.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import {
2+
Compartment,
3+
EditorSelection,
4+
EditorState,
5+
Transaction,
6+
type Extension,
7+
type SelectionRange,
8+
} from "@codemirror/state";
9+
import { EditorView } from "@codemirror/view";
10+
11+
interface PointerTapSnapshot {
12+
pointerId: number;
13+
x: number;
14+
y: number;
15+
timeStamp: number;
16+
isPrimary?: boolean;
17+
button?: number;
18+
cancelled?: boolean;
19+
}
20+
21+
interface PointerTapEnd {
22+
pointerId: number;
23+
x: number;
24+
y: number;
25+
timeStamp: number;
26+
isPrimary?: boolean;
27+
button?: number;
28+
}
29+
30+
interface PointerTapOptions {
31+
maxDelay?: number;
32+
maxDistance?: number;
33+
}
34+
35+
const readOnlyUserChangePattern = /^(?:input|delete|move|undo|redo)(?:\.|$)/;
36+
37+
const readOnlyInputGuard = EditorView.inputHandler.of((view) => {
38+
return view.state.readOnly;
39+
});
40+
41+
const readOnlyUserChangeFilter = EditorState.transactionFilter.of(
42+
(transaction) => {
43+
if (!transaction.startState.readOnly || !transaction.docChanged) {
44+
return transaction;
45+
}
46+
47+
const userEvent = transaction.annotation(Transaction.userEvent);
48+
return isReadOnlyUserChange(userEvent) ? [] : transaction;
49+
},
50+
);
51+
52+
const readOnlyFocusGuard = EditorView.domEventHandlers({
53+
focus(event, view) {
54+
event.preventDefault();
55+
blurEditorIfReadOnly(view, view.state.readOnly);
56+
return true;
57+
},
58+
beforeinput(event, view) {
59+
if (!view.state.readOnly) return false;
60+
event.preventDefault();
61+
blurEditorIfReadOnly(view, true);
62+
return true;
63+
},
64+
});
65+
66+
/**
67+
* Keep CodeMirror's document and DOM editability in sync.
68+
*/
69+
export function createEditorReadOnlyExtension(readOnly: boolean): Extension {
70+
return [
71+
EditorState.readOnly.of(readOnly),
72+
EditorView.editable.of(!readOnly),
73+
...(readOnly
74+
? [readOnlyFocusGuard, readOnlyInputGuard, readOnlyUserChangeFilter]
75+
: []),
76+
];
77+
}
78+
79+
/** Identify document-changing transactions produced by user interaction. */
80+
export function isReadOnlyUserChange(userEvent: string | undefined): boolean {
81+
return !!userEvent && readOnlyUserChangePattern.test(userEvent);
82+
}
83+
84+
/** Dismiss an active soft keyboard when an editor becomes read-only. */
85+
export function blurEditorIfReadOnly(
86+
view: EditorView,
87+
readOnly: boolean,
88+
): void {
89+
if (!readOnly) return;
90+
const activeElement = document.activeElement;
91+
if (
92+
activeElement instanceof HTMLElement &&
93+
view.dom.contains(activeElement)
94+
) {
95+
activeElement.blur();
96+
}
97+
view.contentDOM.blur();
98+
}
99+
100+
/** Atomically update all read-only behavior controlled by the compartment. */
101+
export function reconfigureEditorReadOnly(
102+
view: EditorView,
103+
compartment: Compartment,
104+
readOnly: boolean,
105+
): void {
106+
view.dispatch({
107+
effects: compartment.reconfigure(createEditorReadOnlyExtension(readOnly)),
108+
});
109+
blurEditorIfReadOnly(view, readOnly);
110+
}
111+
112+
/** Focus editable editors and actively settle read-only editors unfocused. */
113+
export function focusEditorIfEditable(view: EditorView): boolean {
114+
if (view.state.readOnly) {
115+
blurEditorIfReadOnly(view, true);
116+
return false;
117+
}
118+
view.focus();
119+
return true;
120+
}
121+
122+
/** Classify a completed pointer gesture as a short primary tap. */
123+
export function shouldCommitReadOnlyTap(
124+
start: PointerTapSnapshot,
125+
end: PointerTapEnd,
126+
options: PointerTapOptions = {},
127+
): boolean {
128+
const { maxDelay = 500, maxDistance = 20 } = options;
129+
if (start.cancelled) return false;
130+
if (start.pointerId !== end.pointerId) return false;
131+
if (start.isPrimary === false || end.isPrimary === false) return false;
132+
if ((start.button ?? 0) !== 0 || (end.button ?? 0) !== 0) return false;
133+
const duration = end.timeStamp - start.timeStamp;
134+
if (!Number.isFinite(duration) || duration < 0 || duration > maxDelay) {
135+
return false;
136+
}
137+
return Math.hypot(end.x - start.x, end.y - start.y) <= maxDistance;
138+
}
139+
140+
/** Collapse an existing read-only selection without focusing or editing. */
141+
export function collapseReadOnlySelection(
142+
view: EditorView,
143+
pos: number,
144+
): boolean {
145+
if (!view.state.readOnly || view.state.selection.main.empty) return false;
146+
const position = Math.max(0, Math.min(pos, view.state.doc.length));
147+
view.dispatch({
148+
selection: EditorSelection.cursor(position),
149+
userEvent: "select.pointer",
150+
});
151+
blurEditorIfReadOnly(view, true);
152+
return true;
153+
}
154+
155+
/** Resolve the text selected by a read-only long-press. */
156+
export function resolveReadOnlyContextSelection(
157+
state: EditorState,
158+
pos: number,
159+
): SelectionRange {
160+
const docLength = state.doc.length;
161+
const position = Math.max(
162+
0,
163+
Math.min(Number.isFinite(pos) ? pos : state.selection.main.head, docLength),
164+
);
165+
const current = state.selection.main;
166+
167+
if (!current.empty && position >= current.from && position <= current.to) {
168+
return current;
169+
}
170+
171+
const word = state.wordAt(position);
172+
if (word) return word;
173+
if (docLength === 0) return EditorSelection.cursor(0);
174+
175+
let from = Math.min(position, docLength - 1);
176+
const unit = state.doc.sliceString(from, from + 1).charCodeAt(0);
177+
if (unit >= 0xdc00 && unit <= 0xdfff && from > 0) {
178+
const previousUnit = state.doc.sliceString(from - 1, from).charCodeAt(0);
179+
if (previousUnit >= 0xd800 && previousUnit <= 0xdbff) from -= 1;
180+
}
181+
182+
const codePoint = state.doc
183+
.sliceString(from, Math.min(from + 2, docLength))
184+
.codePointAt(0);
185+
const width = codePoint != null && codePoint > 0xffff ? 2 : 1;
186+
return EditorSelection.range(from, Math.min(from + width, docLength));
187+
}

src/cm/lineNumberSelection.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EditorSelection } from "@codemirror/state";
22
import type { BlockInfo, EditorView } from "@codemirror/view";
3+
import { focusEditorIfEditable } from "cm/editorReadOnly";
34

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

@@ -115,7 +116,7 @@ export function handleLineNumberClick(
115116
: createLineSelection(range),
116117
userEvent: extendSelection ? "select.extend.pointer" : "select.pointer",
117118
});
118-
view.focus();
119+
focusEditorIfEditable(view);
119120
return true;
120121
}
121122

src/cm/lsp/codeActions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { LSPPlugin } from "@codemirror/lsp-client";
22
import { EditorView } from "@codemirror/view";
3+
import { focusEditorIfEditable } from "cm/editorReadOnly";
34
import toast from "components/toast";
45
import select from "dialogs/select";
56
import type {
@@ -409,15 +410,15 @@ export async function showCodeActionsMenu(view: EditorView): Promise<boolean> {
409410
const index = Number.parseInt(String(result), 10);
410411
if (!Number.isNaN(index) && index >= 0 && index < items.length) {
411412
await executeCodeAction(view, items[index]);
412-
view.focus();
413+
focusEditorIfEditable(view);
413414
return true;
414415
}
415416
}
416417
} catch {
417418
// User cancelled selection
418419
}
419420

420-
view.focus();
421+
focusEditorIfEditable(view);
421422
return false;
422423
}
423424

src/cm/lsp/documentSymbols.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { LSPPlugin } from "@codemirror/lsp-client";
88
import type { EditorView } from "@codemirror/view";
9+
import { focusEditorIfEditable } from "cm/editorReadOnly";
910
import type {
1011
DocumentSymbol,
1112
Position,
@@ -302,7 +303,7 @@ export async function navigateToSymbol(
302303
scrollIntoView: true,
303304
});
304305

305-
view.focus();
306+
focusEditorIfEditable(view);
306307
return true;
307308
} catch (error) {
308309
console.warn("Failed to navigate to symbol:", error);

src/cm/quickToolsModifierInput.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { Extension } from "@codemirror/state";
2-
import { EditorView, type EditorView as CodeMirrorEditorView } from "@codemirror/view";
2+
import {
3+
EditorView,
4+
type EditorView as CodeMirrorEditorView,
5+
} from "@codemirror/view";
6+
import { blurEditorIfReadOnly, focusEditorIfEditable } from "cm/editorReadOnly";
37

48
type QuickToolsModifierInputHandler = (
59
view: CodeMirrorEditorView,
@@ -14,8 +18,38 @@ export function setQuickToolsModifierInputHandler(
1418
handleTextInput = typeof handler === "function" ? handler : () => false;
1519
}
1620

21+
export function canQuickToolsEdit(view: CodeMirrorEditorView): boolean {
22+
return !view.state.readOnly;
23+
}
24+
25+
/** Use the capture input for read-only shortcuts without focusing the editor. */
26+
export function focusQuickToolsModifierInput(
27+
view: CodeMirrorEditorView,
28+
captureInput: HTMLElement,
29+
): boolean {
30+
if (!view.state.readOnly) {
31+
focusEditorIfEditable(view);
32+
return false;
33+
}
34+
blurEditorIfReadOnly(view, true);
35+
captureInput.focus();
36+
return true;
37+
}
38+
39+
/** Close a read-only shortcut capture without disturbing intentional UI focus. */
40+
export function finishQuickToolsModifierInput(
41+
view: CodeMirrorEditorView,
42+
captureInput: HTMLElement,
43+
): boolean {
44+
if (!view.state.readOnly) return false;
45+
captureInput.blur();
46+
blurEditorIfReadOnly(view, true);
47+
return true;
48+
}
49+
1750
export default function quickToolsModifierInput(): Extension {
1851
return EditorView.inputHandler.of((view, _from, _to, text) => {
19-
return !!handleTextInput(view, text);
52+
const handled = !!handleTextInput(view, text);
53+
return view.state.readOnly || handled;
2054
});
2155
}

src/cm/quickToolsNavigation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
type EditorView as CodeMirrorEditorView,
3636
runScopeHandlers,
3737
} from "@codemirror/view";
38+
import { focusEditorIfEditable } from "cm/editorReadOnly";
3839
import createKeyboardEvent from "utils/keyboardEvent";
3940

4041
interface QuickToolKeyModifiers {
@@ -146,15 +147,15 @@ export function runQuickToolKey(
146147

147148
const event = createQuickToolKeyEvent(keyCode, modifiers);
148149
if (runScopeHandlers(view, event, "editor")) {
149-
view.focus();
150+
focusEditorIfEditable(view);
150151
return true;
151152
}
152153

153154
const command = getFallbackCommand(keyCode, modifiers);
154155
if (!command) return false;
155156
const handled = command(view);
156157
if (handled !== false) {
157-
view.focus();
158+
focusEditorIfEditable(view);
158159
return true;
159160
}
160161

src/cm/selectionMenuUtils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface SelectionMenuItem {
2+
mode?: "selected" | "all";
3+
readOnly?: boolean;
4+
}
5+
6+
export interface SelectionMenuFilterOptions {
7+
readOnly: boolean;
8+
hasSelection: boolean;
9+
}
10+
11+
/** Filter selection actions using Acode's read-only and selection rules. */
12+
export function filterSelectionMenuItems<T extends SelectionMenuItem>(
13+
items: readonly T[],
14+
options: SelectionMenuFilterOptions,
15+
): T[] {
16+
const { readOnly, hasSelection } = options;
17+
return items.filter((item) => {
18+
if (readOnly && !item.readOnly) return false;
19+
if (hasSelection && !["selected", "all"].includes(item.mode ?? "all")) {
20+
return false;
21+
}
22+
if (!hasSelection && item.mode === "selected") return false;
23+
return true;
24+
});
25+
}

0 commit comments

Comments
 (0)