Skip to content

Commit 60cd223

Browse files
deadlyjackAjit Kumar
andauthored
fix: quicktools behaviour in read only more (#2747)
Co-authored-by: Ajit Kumar <dellevenjack@gmail>
1 parent 9a50ac8 commit 60cd223

8 files changed

Lines changed: 599 additions & 25 deletions

.babelrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@
1515
"@babel/plugin-transform-runtime",
1616
"@babel/plugin-transform-block-scoping"
1717
],
18-
"compact": true,
18+
"compact": false,
1919
"sourceMaps": "inline"
2020
}

src/cm/editorReadOnly.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ const readOnlyFocusGuard = EditorView.domEventHandlers({
6363
},
6464
});
6565

66+
const readOnlyCursor = [
67+
EditorView.editorAttributes.of({ class: "cm-read-only" }),
68+
EditorView.theme({
69+
"&.cm-read-only > .cm-scroller > .cm-cursorLayer": {
70+
animation: "none",
71+
},
72+
"&.cm-read-only > .cm-scroller > .cm-cursorLayer .cm-cursor": {
73+
display: "block",
74+
},
75+
}),
76+
];
77+
6678
/**
6779
* Keep CodeMirror's document and DOM editability in sync.
6880
*/
@@ -71,7 +83,12 @@ export function createEditorReadOnlyExtension(readOnly: boolean): Extension {
7183
EditorState.readOnly.of(readOnly),
7284
EditorView.editable.of(!readOnly),
7385
...(readOnly
74-
? [readOnlyFocusGuard, readOnlyInputGuard, readOnlyUserChangeFilter]
86+
? [
87+
readOnlyFocusGuard,
88+
readOnlyInputGuard,
89+
readOnlyUserChangeFilter,
90+
readOnlyCursor,
91+
]
7592
: []),
7693
];
7794
}
@@ -137,17 +154,17 @@ export function shouldCommitReadOnlyTap(
137154
return Math.hypot(end.x - start.x, end.y - start.y) <= maxDistance;
138155
}
139156

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;
157+
/** Place the visual read-only cursor without focusing or editing. */
158+
export function placeReadOnlyCursor(view: EditorView, pos: number): boolean {
159+
if (!view.state.readOnly) return false;
146160
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-
});
161+
const selection = EditorSelection.single(position);
162+
if (!view.state.selection.eq(selection)) {
163+
view.dispatch({
164+
selection,
165+
userEvent: "select.pointer",
166+
});
167+
}
151168
blurEditorIfReadOnly(view, true);
152169
return true;
153170
}

src/cm/touchSelectionMenu.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { EditorSelection } from "@codemirror/state";
22
import {
3-
collapseReadOnlySelection,
43
focusEditorIfEditable,
4+
placeReadOnlyCursor,
55
resolveReadOnlyContextSelection,
66
shouldCommitReadOnlyTap,
77
} from "cm/editorReadOnly";
@@ -384,9 +384,7 @@ class TouchSelectionMenuController {
384384

385385
#captureReadOnlyTap(event) {
386386
this.#readOnlyTapSession = null;
387-
if (!this.#enabled || !this.#isReadOnly() || !this.#hasSelection()) {
388-
return;
389-
}
387+
if (!this.#enabled || !this.#isReadOnly()) return;
390388
if (!(event.isTrusted && event.isPrimary)) return;
391389
if (typeof event.button === "number" && event.button !== 0) return;
392390
if (this.#canExtendSelection(event) || this.#canAddSelectionRange(event)) {
@@ -440,7 +438,7 @@ class TouchSelectionMenuController {
440438

441439
const pos = this.#safePosAtCoords(event.clientX, event.clientY);
442440
if (pos == null) return false;
443-
if (!collapseReadOnlySelection(this.#view, pos)) return false;
441+
if (!placeReadOnlyCursor(this.#view, pos)) return false;
444442
this.#menuRequested = false;
445443
this.#clearMenuShowTimer();
446444
this.#hideMenu(true);

src/handlers/quickTools.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ import {
3434
removeActionStackEntries,
3535
shouldCaptureModifierInput,
3636
} from "./quickToolsState";
37+
import {
38+
captureReadOnlyQuickToolsKey,
39+
createReadOnlyQuickToolsCaptureSession,
40+
} from "./readOnlyQuickToolsCapture";
3741

3842
export let quickToolUsed = false;
3943

@@ -44,6 +48,8 @@ let quickToolUsedTimeout = null;
4448
let activeSearchState = null;
4549
/** @type {MutationObserver | null} */
4650
let searchCloseVisibilityObserver = null;
51+
/** @type {import("./readOnlyQuickToolsCapture").ReadOnlyQuickToolsCaptureSession<EditorView> | null} */
52+
let readOnlyCaptureSession = null;
4753

4854
const state = {
4955
shift: false,
@@ -66,7 +72,16 @@ setQuickToolsModifierInputHandler(handleCodeMirrorQuickToolsTextInput);
6672
* @typedef {(value: boolean)=>void} QuickToolsEventListener
6773
*/
6874

75+
quickTools.$input.addEventListener("beforeinput", (event) => {
76+
handleReadOnlyQuickToolsCaptureEvent(event);
77+
});
78+
79+
quickTools.$input.addEventListener("compositionend", (event) => {
80+
handleReadOnlyQuickToolsCaptureEvent(event);
81+
});
82+
6983
quickTools.$input.addEventListener("input", (e) => {
84+
if (handleReadOnlyQuickToolsCaptureEvent(e)) return;
7085
const key = e.target.value.toUpperCase();
7186
quickTools.$input.value = "";
7287
if (!key || key.length > 1) return;
@@ -98,6 +113,7 @@ quickTools.$input.addEventListener("input", (e) => {
98113
});
99114

100115
quickTools.$input.addEventListener("keydown", (e) => {
116+
if (handleReadOnlyQuickToolsCaptureEvent(e)) return;
101117
const { keyCode, key, which } = e;
102118
const keyCombination = getKeys({ keyCode, key, which });
103119

@@ -245,11 +261,15 @@ export const key = {
245261

246262
export function clearQuickToolsModifierState({ restoreFocus = false } = {}) {
247263
const changed = clearModifierState(state, events);
264+
if (!restoreFocus || !readOnlyCaptureSession?.consumed) {
265+
clearReadOnlyCaptureSession();
266+
}
248267
if (restoreFocus) restoreQuickToolsTargetFocus();
249268
return changed;
250269
}
251270

252271
export function cancelQuickToolsModifierInput() {
272+
clearReadOnlyCaptureSession();
253273
const changed = clearQuickToolsModifierState();
254274
quickTools.$input.value = "";
255275
quickTools.$input.blur();
@@ -284,14 +304,21 @@ export default function actions(action, value) {
284304
if (shouldCapture) {
285305
$input.value = "";
286306
if (codeMirrorView?.state.readOnly) {
307+
readOnlyCaptureSession = createReadOnlyQuickToolsCaptureSession(
308+
codeMirrorView,
309+
getQuickToolsModifierSnapshot(),
310+
);
287311
focusQuickToolsModifierInput(codeMirrorView, $input);
288312
} else {
313+
clearReadOnlyCaptureSession();
289314
$input.focus();
290315
}
291316
} else {
317+
clearReadOnlyCaptureSession();
292318
if (codeMirrorView) focusEditorIfEditable(codeMirrorView);
293319
}
294320
} else {
321+
clearReadOnlyCaptureSession();
295322
restoreQuickToolsTargetFocus();
296323
}
297324

@@ -440,6 +467,67 @@ function getCodeMirrorInputView(target) {
440467
: null;
441468
}
442469

470+
function handleReadOnlyQuickToolsCaptureEvent(event) {
471+
const session = readOnlyCaptureSession;
472+
if (!session) return false;
473+
474+
const view = session.target;
475+
if (
476+
!view?.state?.readOnly ||
477+
!view.contentDOM?.isConnected ||
478+
!view.dom?.isConnected
479+
) {
480+
cancelQuickToolsModifierInput();
481+
preventCaptureInput(event);
482+
return true;
483+
}
484+
485+
const result = captureReadOnlyQuickToolsKey(session, {
486+
type: event.type,
487+
key: event.key,
488+
data: event.data,
489+
value: quickTools.$input.value,
490+
inputType: event.inputType,
491+
isComposing: event.isComposing,
492+
});
493+
readOnlyCaptureSession = result.session;
494+
495+
if (result.outcome.kind === "pass") return false;
496+
if (result.outcome.kind === "pending") {
497+
if (!event.isComposing) {
498+
quickTools.$input.value = "";
499+
if (event.type === "beforeinput") preventCaptureInput(event);
500+
}
501+
return true;
502+
}
503+
504+
quickTools.$input.value = "";
505+
preventCaptureInput(event);
506+
if (result.outcome.kind === "duplicate") return true;
507+
508+
const key = result.outcome.key.toUpperCase();
509+
const keyCombination = { key, ...session.modifiers };
510+
runCodeMirrorQuickToolsTextKey(view, key, keyCombination);
511+
return true;
512+
}
513+
514+
function preventCaptureInput(event) {
515+
if (event.cancelable) event.preventDefault();
516+
}
517+
518+
function getQuickToolsModifierSnapshot() {
519+
return {
520+
shiftKey: state.shift,
521+
altKey: state.alt,
522+
ctrlKey: state.ctrl,
523+
metaKey: state.meta,
524+
};
525+
}
526+
527+
function clearReadOnlyCaptureSession() {
528+
readOnlyCaptureSession = null;
529+
}
530+
443531
function runCodeMirrorQuickToolKey(keyCode, keyCombination) {
444532
const view = getCodeMirrorInputView(input);
445533
return view ? runQuickToolKey(view, keyCode, keyCombination) : false;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
interface ReadOnlyCaptureTarget {
2+
state?: {
3+
readOnly?: boolean;
4+
};
5+
}
6+
7+
export interface QuickToolsModifierSnapshot {
8+
shiftKey: boolean;
9+
altKey: boolean;
10+
ctrlKey: boolean;
11+
metaKey: boolean;
12+
}
13+
14+
export interface ReadOnlyQuickToolsCaptureSession<
15+
Target extends ReadOnlyCaptureTarget = ReadOnlyCaptureTarget,
16+
> {
17+
target: Target;
18+
modifiers: QuickToolsModifierSnapshot;
19+
consumed: boolean;
20+
}
21+
22+
export interface ReadOnlyQuickToolsCaptureEvent {
23+
type: "keydown" | "beforeinput" | "input" | "compositionend";
24+
key?: string | null;
25+
data?: string | null;
26+
value?: string | null;
27+
inputType?: string | null;
28+
isComposing?: boolean;
29+
}
30+
31+
export type ReadOnlyQuickToolsCaptureOutcome =
32+
| { kind: "key"; key: string }
33+
| { kind: "pending" }
34+
| { kind: "duplicate" }
35+
| { kind: "pass" };
36+
37+
export interface ReadOnlyQuickToolsCaptureResult<
38+
Target extends ReadOnlyCaptureTarget = ReadOnlyCaptureTarget,
39+
> {
40+
session: ReadOnlyQuickToolsCaptureSession<Target>;
41+
outcome: ReadOnlyQuickToolsCaptureOutcome;
42+
}
43+
44+
/**
45+
* Create a one-shot soft-keyboard capture only for a read-only CodeMirror
46+
* target. Editable editors intentionally stay on the established QuickTools
47+
* input path.
48+
*/
49+
export function createReadOnlyQuickToolsCaptureSession<
50+
Target extends ReadOnlyCaptureTarget,
51+
>(
52+
target: Target | null | undefined,
53+
modifiers: Partial<QuickToolsModifierSnapshot>,
54+
): ReadOnlyQuickToolsCaptureSession<Target> | null {
55+
if (!target?.state?.readOnly) return null;
56+
return {
57+
target,
58+
modifiers: {
59+
shiftKey: !!modifiers.shiftKey,
60+
altKey: !!modifiers.altKey,
61+
ctrlKey: !!modifiers.ctrlKey,
62+
metaKey: !!modifiers.metaKey,
63+
},
64+
consumed: false,
65+
};
66+
}
67+
68+
/**
69+
* Normalize the event variants emitted by Android keyboards. A session is
70+
* consumed by the first unambiguous character and then absorbs duplicate DOM
71+
* events generated for that same keystroke.
72+
*/
73+
export function captureReadOnlyQuickToolsKey<
74+
Target extends ReadOnlyCaptureTarget,
75+
>(
76+
session: ReadOnlyQuickToolsCaptureSession<Target>,
77+
event: ReadOnlyQuickToolsCaptureEvent,
78+
): ReadOnlyQuickToolsCaptureResult<Target> {
79+
if (session.consumed) {
80+
return { session, outcome: { kind: "duplicate" } };
81+
}
82+
83+
if (event.type === "keydown") {
84+
if (isCompositionKey(event.key)) {
85+
return { session, outcome: { kind: "pending" } };
86+
}
87+
// Gboard can mark a real printable key as composing. Waiting for
88+
// compositionend here leaves the shortcut armed until the IME is dismissed.
89+
const key = getSingleCharacter(event.key);
90+
if (!key) return { session, outcome: { kind: "pass" } };
91+
return consumeSession(session, key);
92+
}
93+
94+
if (event.inputType?.startsWith("delete")) {
95+
return { session, outcome: { kind: "pending" } };
96+
}
97+
98+
if (event.data !== null && event.data !== undefined) {
99+
// A single composition update is already an unambiguous shortcut key.
100+
const key = getSingleCharacter(event.data);
101+
return key
102+
? consumeSession(session, key)
103+
: { session, outcome: { kind: "pending" } };
104+
}
105+
106+
const key = getSingleCharacter(event.value);
107+
return key
108+
? consumeSession(session, key)
109+
: { session, outcome: { kind: "pending" } };
110+
}
111+
112+
function consumeSession<Target extends ReadOnlyCaptureTarget>(
113+
session: ReadOnlyQuickToolsCaptureSession<Target>,
114+
key: string,
115+
): ReadOnlyQuickToolsCaptureResult<Target> {
116+
return {
117+
session: { ...session, consumed: true },
118+
outcome: { kind: "key", key },
119+
};
120+
}
121+
122+
function getSingleCharacter(value: string | null | undefined): string | null {
123+
if (!value) return null;
124+
const characters = Array.from(value);
125+
return characters.length === 1 ? characters[0] : null;
126+
}
127+
128+
function isCompositionKey(key: string | null | undefined): boolean {
129+
return key === "Dead" || key === "Process" || key === "Unidentified";
130+
}

0 commit comments

Comments
 (0)