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
2 changes: 1 addition & 1 deletion .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-block-scoping"
],
"compact": true,
"compact": false,
"sourceMaps": "inline"
}
39 changes: 28 additions & 11 deletions src/cm/editorReadOnly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ const readOnlyFocusGuard = EditorView.domEventHandlers({
},
});

const readOnlyCursor = [
EditorView.editorAttributes.of({ class: "cm-read-only" }),
EditorView.theme({
"&.cm-read-only > .cm-scroller > .cm-cursorLayer": {
animation: "none",
},
"&.cm-read-only > .cm-scroller > .cm-cursorLayer .cm-cursor": {
display: "block",
},
}),
];

/**
* Keep CodeMirror's document and DOM editability in sync.
*/
Expand All @@ -71,7 +83,12 @@ export function createEditorReadOnlyExtension(readOnly: boolean): Extension {
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
...(readOnly
? [readOnlyFocusGuard, readOnlyInputGuard, readOnlyUserChangeFilter]
? [
readOnlyFocusGuard,
readOnlyInputGuard,
readOnlyUserChangeFilter,
readOnlyCursor,
]
: []),
];
}
Expand Down Expand Up @@ -137,17 +154,17 @@ export function shouldCommitReadOnlyTap(
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;
/** Place the visual read-only cursor without focusing or editing. */
export function placeReadOnlyCursor(view: EditorView, pos: number): boolean {
if (!view.state.readOnly) return false;
const position = Math.max(0, Math.min(pos, view.state.doc.length));
view.dispatch({
selection: EditorSelection.cursor(position),
userEvent: "select.pointer",
});
const selection = EditorSelection.single(position);
if (!view.state.selection.eq(selection)) {
view.dispatch({
selection,
userEvent: "select.pointer",
});
}
blurEditorIfReadOnly(view, true);
return true;
}
Expand Down
8 changes: 3 additions & 5 deletions src/cm/touchSelectionMenu.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EditorSelection } from "@codemirror/state";
import {
collapseReadOnlySelection,
focusEditorIfEditable,
placeReadOnlyCursor,
resolveReadOnlyContextSelection,
shouldCommitReadOnlyTap,
} from "cm/editorReadOnly";
Expand Down Expand Up @@ -384,9 +384,7 @@ class TouchSelectionMenuController {

#captureReadOnlyTap(event) {
this.#readOnlyTapSession = null;
if (!this.#enabled || !this.#isReadOnly() || !this.#hasSelection()) {
return;
}
if (!this.#enabled || !this.#isReadOnly()) return;
if (!(event.isTrusted && event.isPrimary)) return;
if (typeof event.button === "number" && event.button !== 0) return;
if (this.#canExtendSelection(event) || this.#canAddSelectionRange(event)) {
Expand Down Expand Up @@ -440,7 +438,7 @@ class TouchSelectionMenuController {

const pos = this.#safePosAtCoords(event.clientX, event.clientY);
if (pos == null) return false;
if (!collapseReadOnlySelection(this.#view, pos)) return false;
if (!placeReadOnlyCursor(this.#view, pos)) return false;
this.#menuRequested = false;
this.#clearMenuShowTimer();
this.#hideMenu(true);
Expand Down
88 changes: 88 additions & 0 deletions src/handlers/quickTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ import {
removeActionStackEntries,
shouldCaptureModifierInput,
} from "./quickToolsState";
import {
captureReadOnlyQuickToolsKey,
createReadOnlyQuickToolsCaptureSession,
} from "./readOnlyQuickToolsCapture";

export let quickToolUsed = false;

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

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

quickTools.$input.addEventListener("beforeinput", (event) => {
handleReadOnlyQuickToolsCaptureEvent(event);
});

quickTools.$input.addEventListener("compositionend", (event) => {
handleReadOnlyQuickToolsCaptureEvent(event);
});

quickTools.$input.addEventListener("input", (e) => {
if (handleReadOnlyQuickToolsCaptureEvent(e)) return;
const key = e.target.value.toUpperCase();
quickTools.$input.value = "";
if (!key || key.length > 1) return;
Expand Down Expand Up @@ -98,6 +113,7 @@ quickTools.$input.addEventListener("input", (e) => {
});

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

Expand Down Expand Up @@ -245,11 +261,15 @@ export const key = {

export function clearQuickToolsModifierState({ restoreFocus = false } = {}) {
const changed = clearModifierState(state, events);
if (!restoreFocus || !readOnlyCaptureSession?.consumed) {
clearReadOnlyCaptureSession();
}
if (restoreFocus) restoreQuickToolsTargetFocus();
return changed;
}

export function cancelQuickToolsModifierInput() {
clearReadOnlyCaptureSession();
const changed = clearQuickToolsModifierState();
quickTools.$input.value = "";
quickTools.$input.blur();
Expand Down Expand Up @@ -284,14 +304,21 @@ export default function actions(action, value) {
if (shouldCapture) {
$input.value = "";
if (codeMirrorView?.state.readOnly) {
readOnlyCaptureSession = createReadOnlyQuickToolsCaptureSession(
codeMirrorView,
getQuickToolsModifierSnapshot(),
);
focusQuickToolsModifierInput(codeMirrorView, $input);
} else {
clearReadOnlyCaptureSession();
$input.focus();
}
} else {
clearReadOnlyCaptureSession();
if (codeMirrorView) focusEditorIfEditable(codeMirrorView);
}
} else {
clearReadOnlyCaptureSession();
restoreQuickToolsTargetFocus();
}

Expand Down Expand Up @@ -440,6 +467,67 @@ function getCodeMirrorInputView(target) {
: null;
}

function handleReadOnlyQuickToolsCaptureEvent(event) {
const session = readOnlyCaptureSession;
if (!session) return false;

const view = session.target;
if (
!view?.state?.readOnly ||
!view.contentDOM?.isConnected ||
!view.dom?.isConnected
) {
cancelQuickToolsModifierInput();
preventCaptureInput(event);
return true;
}

const result = captureReadOnlyQuickToolsKey(session, {
type: event.type,
key: event.key,
data: event.data,
value: quickTools.$input.value,
inputType: event.inputType,
isComposing: event.isComposing,
});
readOnlyCaptureSession = result.session;

if (result.outcome.kind === "pass") return false;
if (result.outcome.kind === "pending") {
if (!event.isComposing) {
quickTools.$input.value = "";
if (event.type === "beforeinput") preventCaptureInput(event);
}
return true;
}

quickTools.$input.value = "";
preventCaptureInput(event);
if (result.outcome.kind === "duplicate") return true;

const key = result.outcome.key.toUpperCase();
const keyCombination = { key, ...session.modifiers };
runCodeMirrorQuickToolsTextKey(view, key, keyCombination);
return true;
}

function preventCaptureInput(event) {
if (event.cancelable) event.preventDefault();
}

function getQuickToolsModifierSnapshot() {
return {
shiftKey: state.shift,
altKey: state.alt,
ctrlKey: state.ctrl,
metaKey: state.meta,
};
}

function clearReadOnlyCaptureSession() {
readOnlyCaptureSession = null;
}

function runCodeMirrorQuickToolKey(keyCode, keyCombination) {
const view = getCodeMirrorInputView(input);
return view ? runQuickToolKey(view, keyCode, keyCombination) : false;
Expand Down
130 changes: 130 additions & 0 deletions src/handlers/readOnlyQuickToolsCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
interface ReadOnlyCaptureTarget {
state?: {
readOnly?: boolean;
};
}

export interface QuickToolsModifierSnapshot {
shiftKey: boolean;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}

export interface ReadOnlyQuickToolsCaptureSession<
Target extends ReadOnlyCaptureTarget = ReadOnlyCaptureTarget,
> {
target: Target;
modifiers: QuickToolsModifierSnapshot;
consumed: boolean;
}

export interface ReadOnlyQuickToolsCaptureEvent {
type: "keydown" | "beforeinput" | "input" | "compositionend";
key?: string | null;
data?: string | null;
value?: string | null;
inputType?: string | null;
isComposing?: boolean;
}

export type ReadOnlyQuickToolsCaptureOutcome =
| { kind: "key"; key: string }
| { kind: "pending" }
| { kind: "duplicate" }
| { kind: "pass" };

export interface ReadOnlyQuickToolsCaptureResult<
Target extends ReadOnlyCaptureTarget = ReadOnlyCaptureTarget,
> {
session: ReadOnlyQuickToolsCaptureSession<Target>;
outcome: ReadOnlyQuickToolsCaptureOutcome;
}

/**
* Create a one-shot soft-keyboard capture only for a read-only CodeMirror
* target. Editable editors intentionally stay on the established QuickTools
* input path.
*/
export function createReadOnlyQuickToolsCaptureSession<
Target extends ReadOnlyCaptureTarget,
>(
target: Target | null | undefined,
modifiers: Partial<QuickToolsModifierSnapshot>,
): ReadOnlyQuickToolsCaptureSession<Target> | null {
if (!target?.state?.readOnly) return null;
return {
target,
modifiers: {
shiftKey: !!modifiers.shiftKey,
altKey: !!modifiers.altKey,
ctrlKey: !!modifiers.ctrlKey,
metaKey: !!modifiers.metaKey,
},
consumed: false,
};
}

/**
* Normalize the event variants emitted by Android keyboards. A session is
* consumed by the first unambiguous character and then absorbs duplicate DOM
* events generated for that same keystroke.
*/
export function captureReadOnlyQuickToolsKey<
Target extends ReadOnlyCaptureTarget,
>(
session: ReadOnlyQuickToolsCaptureSession<Target>,
event: ReadOnlyQuickToolsCaptureEvent,
): ReadOnlyQuickToolsCaptureResult<Target> {
if (session.consumed) {
return { session, outcome: { kind: "duplicate" } };
}

if (event.type === "keydown") {
if (isCompositionKey(event.key)) {
return { session, outcome: { kind: "pending" } };
}
// Gboard can mark a real printable key as composing. Waiting for
// compositionend here leaves the shortcut armed until the IME is dismissed.
const key = getSingleCharacter(event.key);
if (!key) return { session, outcome: { kind: "pass" } };
return consumeSession(session, key);
}

if (event.inputType?.startsWith("delete")) {
return { session, outcome: { kind: "pending" } };
}

if (event.data !== null && event.data !== undefined) {
// A single composition update is already an unambiguous shortcut key.
const key = getSingleCharacter(event.data);
return key
? consumeSession(session, key)
: { session, outcome: { kind: "pending" } };
}

const key = getSingleCharacter(event.value);
return key
? consumeSession(session, key)
: { session, outcome: { kind: "pending" } };
}

function consumeSession<Target extends ReadOnlyCaptureTarget>(
session: ReadOnlyQuickToolsCaptureSession<Target>,
key: string,
): ReadOnlyQuickToolsCaptureResult<Target> {
return {
session: { ...session, consumed: true },
outcome: { kind: "key", key },
};
}

function getSingleCharacter(value: string | null | undefined): string | null {
if (!value) return null;
const characters = Array.from(value);
return characters.length === 1 ? characters[0] : null;
}

function isCompositionKey(key: string | null | undefined): boolean {
return key === "Dead" || key === "Process" || key === "Unidentified";
}
Loading