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
31 changes: 28 additions & 3 deletions src/cm/quickToolsModifierInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import {
} from "@codemirror/view";
import { blurEditorIfReadOnly, focusEditorIfEditable } from "cm/editorReadOnly";

export interface QuickToolsModifierInputContext {
from: number;
to: number;
text: string;
}

type QuickToolsModifierInputHandler = (
view: CodeMirrorEditorView,
text: string,
input: QuickToolsModifierInputContext,
) => boolean | void;

let handleTextInput: QuickToolsModifierInputHandler = () => false;
Expand All @@ -18,6 +24,21 @@ export function setQuickToolsModifierInputHandler(
handleTextInput = typeof handler === "function" ? handler : () => false;
}

/**
* Android may report typing over a selection as an empty deletion followed by
* a separate insertion. Quick-tools modifiers must hold that deletion until
* the character arrives, otherwise shortcuts such as Ctrl+C copy only after
* the selected text has already been removed.
*/
export function isSelectedRangeDeletion(
view: CodeMirrorEditorView,
input: QuickToolsModifierInputContext,
): boolean {
const selection = view?.state?.selection?.main;
if (!selection || selection.empty || input.text !== "") return false;
return input.from <= selection.from && input.to >= selection.to;
}

export function canQuickToolsEdit(view: CodeMirrorEditorView): boolean {
return !view.state.readOnly;
}
Expand Down Expand Up @@ -48,8 +69,12 @@ export function finishQuickToolsModifierInput(
}

export default function quickToolsModifierInput(): Extension {
return EditorView.inputHandler.of((view, _from, _to, text) => {
const handled = !!handleTextInput(view, text);
return EditorView.inputHandler.of((view, from, to, text) => {
// When a DOM-derived input is handled without changing state, CodeMirror's
// DOM observer performs its own view.update([]) reconciliation. Dispatching
// here would make that observer think state changed and can leave Android's
// native replacement in the content DOM.
const handled = !!handleTextInput(view, { from, to, text });
return view.state.readOnly || handled;
});
}
98 changes: 63 additions & 35 deletions src/handlers/quickTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
SearchQuery,
setSearchQuery,
} from "@codemirror/search";
import { EditorView } from "@codemirror/view";
import { executeCommand, getRegisteredCommands } from "cm/commandRegistry";
import { focusEditorIfEditable } from "cm/editorReadOnly";
import {
canQuickToolsEdit,
finishQuickToolsModifierInput,
focusQuickToolsModifierInput,
isSelectedRangeDeletion,
setQuickToolsModifierInputHandler,
} from "cm/quickToolsModifierInput";
import {
Expand All @@ -30,6 +32,7 @@ import {
clearModifierState,
clearQuickToolsButtonFeedback,
removeActionStackEntries,
shouldCaptureModifierInput,
} from "./quickToolsState";

export let quickToolUsed = false;
Expand Down Expand Up @@ -69,13 +72,12 @@ quickTools.$input.addEventListener("input", (e) => {
if (!key || key.length > 1) return;
const keyCombination = getKeys({ key });
const target = getInput();
const codeMirrorView = getCodeMirrorInputView(target);

if (isCodeMirrorEditorInput(target)) {
handleCodeMirrorQuickToolsTextInput(
editorManager.editor,
key,
keyCombination,
);
if (
codeMirrorView &&
runCodeMirrorQuickToolsTextKey(codeMirrorView, key, keyCombination)
) {
return;
}

Expand Down Expand Up @@ -108,20 +110,27 @@ quickTools.$input.addEventListener("keydown", (e) => {
e.preventDefault();

let target = getInput();
if (isCodeMirrorEditorInput(target)) {
const codeMirrorView = getCodeMirrorInputView(target);
if (codeMirrorView) {
try {
runCodeMirrorQuickToolKey(keyCode, keyCombination);
const handled = runQuickToolKey(codeMirrorView, keyCode, keyCombination);
if (!handled && !codeMirrorView.state.readOnly) {
target.dispatchEvent(KeyboardEvent("keydown", keyCombination));
}
} finally {
resetKeys();
dismissReadOnlyQuickToolsInput(editorManager.editor);
if (codeMirrorView.state.readOnly) {
resetKeys();
dismissReadOnlyQuickToolsInput(codeMirrorView);
}
setQuicktoolsUsed();
}
return;
}
if (target === quickTools.$input) {
target = editorManager.editor.contentDOM;
target = editorManager.editor?.contentDOM;
}

target.dispatchEvent(KeyboardEvent("keydown", keyCombination));
target?.dispatchEvent(KeyboardEvent("keydown", keyCombination));
setQuicktoolsUsed();
});

Expand Down Expand Up @@ -244,7 +253,9 @@ export function cancelQuickToolsModifierInput() {
const changed = clearQuickToolsModifierState();
quickTools.$input.value = "";
quickTools.$input.blur();
dismissReadOnlyQuickToolsInput(editorManager.editor);
dismissReadOnlyQuickToolsInput(
getCodeMirrorInputView(input) || editorManager.editor,
);
return changed;
}

Expand All @@ -266,10 +277,19 @@ export default function actions(action, value) {
state[action] = value;
events[action].forEach((cb) => cb(value));
if (Object.values(state).includes(true)) {
if (isCodeMirrorEditorInput(input)) {
focusQuickToolsModifierInput(editor, $input);
const codeMirrorView = getCodeMirrorInputView(input);
const shouldCapture =
codeMirrorView?.state.readOnly ||
shouldCaptureModifierInput(state, Boolean(codeMirrorView));
if (shouldCapture) {
$input.value = "";
if (codeMirrorView?.state.readOnly) {
focusQuickToolsModifierInput(codeMirrorView, $input);
} else {
$input.focus();
}
} else {
$input.focus();
if (codeMirrorView) focusEditorIfEditable(codeMirrorView);
}
} else {
restoreQuickToolsTargetFocus();
Expand Down Expand Up @@ -411,27 +431,36 @@ function setInput() {
input = activeElement;
}

function isCodeMirrorEditorInput(target) {
const { editor, activeFile } = editorManager;
if (!editor || activeFile?.type !== "editor") return false;
const contentDOM = editor.contentDOM;
return target === contentDOM || (contentDOM?.contains?.(target) ?? false);
function getCodeMirrorInputView(target) {
if (!(target instanceof HTMLElement)) return null;
const view = EditorView.findFromDOM(target);
if (!view?.contentDOM) return null;
return target === view.contentDOM || view.contentDOM.contains(target)
? view
: null;
}

function runCodeMirrorQuickToolKey(keyCode, keyCombination) {
if (!isCodeMirrorEditorInput(input)) return false;
return runQuickToolKey(editorManager.editor, keyCode, keyCombination);
const view = getCodeMirrorInputView(input);
return view ? runQuickToolKey(view, keyCode, keyCombination) : false;
}

export function handleCodeMirrorQuickToolsTextInput(
view,
text,
modifiers = null,
) {
export function handleCodeMirrorQuickToolsTextInput(view, input) {
if (!Object.values(state).includes(true)) return false;
if (!view?.state || !view.contentDOM) return false;
if (isSelectedRangeDeletion(view, input)) {
setQuicktoolsUsed();
return true;
}

const { text } = input;
if (!text || text.length !== 1) return false;

const keyCombination = modifiers || getKeys({ key: text });
const keyCombination = getKeys({ key: text });
return runCodeMirrorQuickToolsTextKey(view, text, keyCombination);
}

function runCodeMirrorQuickToolsTextKey(view, text, keyCombination) {
if (!hasQuickToolsModifier(keyCombination)) return false;
const canEdit = canQuickToolsEdit(view);
resetKeys();
Expand Down Expand Up @@ -921,8 +950,7 @@ function blockReadOnlyQuickToolsEdit(view) {
}

function isReadOnlyCodeMirrorInput(target) {
const { editor } = editorManager;
return isCodeMirrorEditorInput(target) && !!editor?.state?.readOnly;
return !!getCodeMirrorInputView(target)?.state.readOnly;
}

function dismissReadOnlyQuickToolsInput(view) {
Expand All @@ -931,10 +959,10 @@ function dismissReadOnlyQuickToolsInput(view) {
}

function restoreQuickToolsTargetFocus() {
const { editor } = editorManager;
if (isCodeMirrorEditorInput(input) && editor) {
if (dismissReadOnlyQuickToolsInput(editor)) return;
focusEditorIfEditable(editor);
const codeMirrorView = getCodeMirrorInputView(input);
if (codeMirrorView) {
if (dismissReadOnlyQuickToolsInput(codeMirrorView)) return;
focusEditorIfEditable(codeMirrorView);
return;
}
if (input) {
Expand Down
12 changes: 12 additions & 0 deletions src/handlers/quickToolsState.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
export const modifierKeys = ["shift", "alt", "ctrl", "meta"];

/**
* CodeMirror can safely receive Shift-only text because replacing the current
* selection is intentional. Command modifiers must capture text outside the
* contenteditable so Android keyboards cannot mutate the document first.
*/
export function shouldCaptureModifierInput(state, isCodeMirrorTarget) {
const hasActiveModifier = modifierKeys.some((key) => Boolean(state[key]));
if (!hasActiveModifier) return false;
if (!isCodeMirrorTarget) return true;
return Boolean(state.ctrl || state.alt || state.meta);
}

export function clearModifierState(state, events = {}) {
let changed = false;

Expand Down
Loading
Loading