621 lines
17 KiB
TypeScript
621 lines
17 KiB
TypeScript
import {
|
|
cursorCharLeft,
|
|
cursorCharRight,
|
|
cursorDocEnd,
|
|
cursorDocStart,
|
|
cursorGroupLeft,
|
|
cursorGroupRight,
|
|
cursorLineBoundaryLeft,
|
|
cursorLineBoundaryRight,
|
|
cursorLineDown,
|
|
cursorLineEnd,
|
|
cursorLineStart,
|
|
cursorLineUp,
|
|
cursorPageDown,
|
|
cursorPageUp,
|
|
deleteCharBackward,
|
|
deleteCharForward,
|
|
deleteGroupBackward,
|
|
deleteGroupForward,
|
|
deleteLine,
|
|
deleteLineBoundaryBackward,
|
|
deleteLineBoundaryForward,
|
|
indentLess,
|
|
indentMore,
|
|
insertNewlineAndIndent,
|
|
redo,
|
|
selectAll,
|
|
selectCharLeft,
|
|
selectCharRight,
|
|
selectDocEnd,
|
|
selectDocStart,
|
|
selectGroupLeft,
|
|
selectGroupRight,
|
|
selectLineBoundaryLeft,
|
|
selectLineBoundaryRight,
|
|
selectLineDown,
|
|
selectLineEnd,
|
|
selectLineStart,
|
|
selectLineUp,
|
|
selectPageDown,
|
|
selectPageUp,
|
|
transposeChars,
|
|
undo,
|
|
} from "@codemirror/commands";
|
|
import {
|
|
acceptCompletion,
|
|
closeCompletion,
|
|
moveCompletionSelection,
|
|
startCompletion,
|
|
} from "@codemirror/autocomplete";
|
|
import { openSearchPanel } from "@codemirror/search";
|
|
import { EditorSelection } from "@codemirror/state";
|
|
import type { EditorView } from "@codemirror/view";
|
|
import { reloadAllWidgets } from "./codemirror/code_widget.ts";
|
|
import { broadcastReload } from "./components/widget_sandbox_iframe.ts";
|
|
import type { Client } from "./client.ts";
|
|
import type { CommandHook } from "./plugos/hooks/command.ts";
|
|
import {
|
|
unbakeSectionAtCursor,
|
|
updateBakedSections,
|
|
} from "./baked_sections/bake.ts";
|
|
|
|
/**
|
|
* Block widgets (queries, tables, …) hide their multi-line source via
|
|
* `hideBlockSource`: one line stays visible to host the widget, the rest
|
|
* collapse to zero height with `display:none`. CodeMirror's geometric
|
|
* `cursorLineUp/Down` then lands on whichever line is visible — so the
|
|
* cursor appears to jump several document lines when entering the block.
|
|
*
|
|
* After the default motion, if the cursor jumped more than one document
|
|
* line in the intended direction, re-snap to the adjacent line. That
|
|
* lands cursor on the first hidden source line when going down, and the
|
|
* last one when going up. Either entry expands the block (via
|
|
* `isCursorInRange`) so the source becomes editable.
|
|
*/
|
|
function withCollapsedBlockSnap(
|
|
motion: (v: EditorView) => boolean,
|
|
forward: boolean,
|
|
): (v: EditorView) => boolean {
|
|
return (v: EditorView) => {
|
|
const prev = v.state.selection.main;
|
|
const prevLine = v.state.doc.lineAt(prev.head).number;
|
|
const result = motion(v);
|
|
const next = v.state.selection.main;
|
|
if (next.head === prev.head) return result;
|
|
const newLine = v.state.doc.lineAt(next.head).number;
|
|
const diff = forward ? newLine - prevLine : prevLine - newLine;
|
|
if (diff <= 1) return result;
|
|
const targetLineNo = forward ? prevLine + 1 : prevLine - 1;
|
|
const targetPos = v.state.doc.line(targetLineNo).from;
|
|
v.dispatch({
|
|
selection: prev.empty
|
|
? EditorSelection.cursor(targetPos)
|
|
: EditorSelection.range(prev.anchor, targetPos),
|
|
scrollIntoView: true,
|
|
});
|
|
return result;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Registers client-side editor commands with the CommandHook. These were
|
|
* previously defined in the editor plug; moving them into the client makes
|
|
* them synchronous (no async round-trip through the plug Web Worker) and
|
|
* avoids losing key events when the user types faster than the worker
|
|
* responds. They are still exposed through the official command mechanism
|
|
* so Lua scripts can rebind them.
|
|
*
|
|
* The `hook` is passed explicitly because this runs from inside the
|
|
* `ClientSystem` constructor, before `client.clientSystem` has been assigned.
|
|
*/
|
|
export function registerEditorCommands(
|
|
client: Client,
|
|
hook: CommandHook,
|
|
): void {
|
|
const view = () => client.editorView;
|
|
|
|
// Enter: accept completion if popup is open, else newline-and-indent
|
|
hook.registerCommand({
|
|
name: "Editor: Insert Newline",
|
|
key: "Enter",
|
|
mac: "Enter",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => {
|
|
const v = view();
|
|
if (acceptCompletion(v)) return true;
|
|
return insertNewlineAndIndent(v);
|
|
},
|
|
});
|
|
|
|
// Delete
|
|
hook.registerCommand({
|
|
name: "Editor: Delete Char Backward",
|
|
key: ["Backspace", "Ctrl-h"],
|
|
mac: ["Backspace", "Ctrl-h"],
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => deleteCharBackward(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Delete Char Forward",
|
|
key: "Delete",
|
|
mac: "Delete",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => deleteCharForward(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Delete Group Backward",
|
|
key: "Ctrl-Backspace",
|
|
mac: "Alt-Backspace",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => deleteGroupBackward(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Delete Group Forward",
|
|
key: "Ctrl-Delete",
|
|
mac: "Alt-Delete",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => deleteGroupForward(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Delete Line Boundary Backward",
|
|
mac: "Cmd-Backspace",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => deleteLineBoundaryBackward(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Delete Line Boundary Forward",
|
|
mac: "Cmd-Delete",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => deleteLineBoundaryForward(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Transpose Chars",
|
|
key: "Ctrl-t",
|
|
mac: "Ctrl-t",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => transposeChars(view()),
|
|
});
|
|
|
|
// Cursor motions
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Char Left",
|
|
key: "ArrowLeft",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorCharLeft(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Char Right",
|
|
key: "ArrowRight",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorCharRight(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Group Left",
|
|
key: "Ctrl-ArrowLeft",
|
|
mac: "Alt-ArrowLeft",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorGroupLeft(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Group Right",
|
|
key: "Ctrl-ArrowRight",
|
|
mac: "Alt-ArrowRight",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorGroupRight(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Line Boundary Left",
|
|
mac: "Cmd-ArrowLeft",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorLineBoundaryLeft(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Line Boundary Right",
|
|
mac: "Cmd-ArrowRight",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorLineBoundaryRight(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Line Start",
|
|
key: "Home",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorLineStart(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Line End",
|
|
key: ["End", "Ctrl-e"],
|
|
mac: ["End", "Ctrl-e"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorLineEnd(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Doc Start",
|
|
key: "Ctrl-Home",
|
|
mac: ["Ctrl-Home", "Cmd-ArrowUp"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorDocStart(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Doc End",
|
|
key: "Ctrl-End",
|
|
mac: ["Ctrl-End", "Cmd-ArrowDown"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => cursorDocEnd(view()),
|
|
});
|
|
const cursorLineUpSnap = withCollapsedBlockSnap(cursorLineUp, false);
|
|
const cursorLineDownSnap = withCollapsedBlockSnap(cursorLineDown, true);
|
|
const selectLineUpSnap = withCollapsedBlockSnap(selectLineUp, false);
|
|
const selectLineDownSnap = withCollapsedBlockSnap(selectLineDown, true);
|
|
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Line Up",
|
|
key: ["ArrowUp", "Ctrl-p"],
|
|
mac: "ArrowUp",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => {
|
|
const v = view();
|
|
if (moveCompletionSelection(false)(v)) return true;
|
|
return cursorLineUpSnap(v);
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Line Down",
|
|
key: "ArrowDown",
|
|
mac: "ArrowDown",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => {
|
|
const v = view();
|
|
if (moveCompletionSelection(true)(v)) return true;
|
|
return cursorLineDownSnap(v);
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Page Up",
|
|
key: "PageUp",
|
|
mac: ["PageUp", "Ctrl-ArrowUp"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => {
|
|
const v = view();
|
|
if (moveCompletionSelection(false, "page")(v)) return true;
|
|
return cursorPageUp(v);
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Cursor Page Down",
|
|
key: "PageDown",
|
|
mac: ["PageDown", "Ctrl-v", "Ctrl-ArrowDown"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => {
|
|
const v = view();
|
|
if (moveCompletionSelection(true, "page")(v)) return true;
|
|
return cursorPageDown(v);
|
|
},
|
|
});
|
|
|
|
// Selection-extending motions
|
|
hook.registerCommand({
|
|
name: "Editor: Select Char Left",
|
|
key: "Shift-ArrowLeft",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectCharLeft(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Char Right",
|
|
key: "Shift-ArrowRight",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectCharRight(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Group Left",
|
|
key: "Shift-Ctrl-ArrowLeft",
|
|
mac: "Shift-Alt-ArrowLeft",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectGroupLeft(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Group Right",
|
|
key: "Shift-Ctrl-ArrowRight",
|
|
mac: "Shift-Alt-ArrowRight",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectGroupRight(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Line Boundary Left",
|
|
mac: "Shift-Cmd-ArrowLeft",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectLineBoundaryLeft(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Line Boundary Right",
|
|
mac: "Shift-Cmd-ArrowRight",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectLineBoundaryRight(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Line Start",
|
|
key: "Shift-Home",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectLineStart(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Line End",
|
|
key: ["Shift-End", "Shift-Ctrl-e"],
|
|
mac: ["Shift-End", "Shift-Ctrl-e"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectLineEnd(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Doc Start",
|
|
key: "Shift-Ctrl-Home",
|
|
mac: ["Shift-Ctrl-Home", "Shift-Cmd-ArrowUp"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectDocStart(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Doc End",
|
|
key: "Shift-Ctrl-End",
|
|
mac: ["Shift-Ctrl-End", "Shift-Cmd-ArrowDown"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectDocEnd(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Line Up",
|
|
key: ["Shift-ArrowUp", "Shift-Ctrl-p"],
|
|
mac: "Shift-ArrowUp",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectLineUpSnap(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Line Down",
|
|
key: "Shift-ArrowDown",
|
|
mac: "Shift-ArrowDown",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectLineDownSnap(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Page Up",
|
|
key: "Shift-PageUp",
|
|
mac: ["Shift-PageUp", "Shift-Ctrl-ArrowUp"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectPageUp(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Select Page Down",
|
|
key: "Shift-PageDown",
|
|
mac: ["Shift-PageDown", "Shift-Ctrl-ArrowDown"],
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => selectPageDown(view()),
|
|
});
|
|
|
|
// Selection / indentation
|
|
hook.registerCommand({
|
|
name: "Editor: Select All",
|
|
key: "Ctrl-a",
|
|
mac: "Cmd-a",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => {
|
|
const v = view();
|
|
return selectAll({ state: v.state, dispatch: v.dispatch });
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Indent",
|
|
key: "Tab",
|
|
mac: "Tab",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
run: async () => {
|
|
const v = view();
|
|
// Accept completion popup suggestion if open, else indent
|
|
if (acceptCompletion(v)) return true;
|
|
return indentMore({ state: v.state, dispatch: v.dispatch });
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Outdent",
|
|
key: "Shift-Tab",
|
|
mac: "Shift-Tab",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
run: async () => {
|
|
const v = view();
|
|
return indentLess({ state: v.state, dispatch: v.dispatch });
|
|
},
|
|
});
|
|
|
|
// Undo / redo
|
|
hook.registerCommand({
|
|
name: "Editor: Undo",
|
|
key: "Ctrl-z",
|
|
mac: "Cmd-z",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
menu: { location: "edit", group: "1_undo", order: 1, label: "Undo" },
|
|
run: async () => undo(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Redo",
|
|
key: "Ctrl-y",
|
|
mac: "Cmd-Shift-z",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
menu: { location: "edit", group: "1_undo", order: 2, label: "Redo" },
|
|
run: async () => redo(view()),
|
|
});
|
|
|
|
// Delete line
|
|
hook.registerCommand({
|
|
name: "Delete Line",
|
|
key: "Ctrl-d",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
run: async () => deleteLine(view()),
|
|
});
|
|
|
|
// Completion popup
|
|
hook.registerCommand({
|
|
name: "Editor: Start Completion",
|
|
key: "Ctrl-Space",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => startCompletion(view()),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Close Completion",
|
|
key: "Escape",
|
|
requireEditor: "page",
|
|
disableInVim: true,
|
|
run: async () => closeCompletion(view()),
|
|
});
|
|
|
|
// Openers (modal UI — not in the typing hot path, but moving them out of
|
|
// the plug removes an unnecessary worker round-trip).
|
|
hook.registerCommand({
|
|
name: "Open Command Palette",
|
|
key: "Ctrl-/",
|
|
mac: "Cmd-/",
|
|
menu: { location: "file", group: "3_palette", label: "Command Palette..." },
|
|
run: async () => client.startCommandPalette(),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Navigate: Page Picker",
|
|
key: "Ctrl-k",
|
|
mac: "Cmd-k",
|
|
menu: [
|
|
{ location: "file", group: "1_new", order: 2, label: "Open Page..." },
|
|
{ location: "navigate", group: "2_picker", order: 1, label: "Page..." },
|
|
],
|
|
run: async () => client.startPageNavigate("page"),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Navigate: Meta Picker",
|
|
key: "Ctrl-Shift-k",
|
|
mac: "Cmd-Shift-k",
|
|
priority: 2,
|
|
menu: {
|
|
location: "navigate",
|
|
group: "2_picker",
|
|
order: 4,
|
|
label: "Meta Page...",
|
|
},
|
|
run: async () => client.startPageNavigate("meta"),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Navigate: Document Picker",
|
|
key: "Ctrl-o",
|
|
mac: "Cmd-o",
|
|
priority: 2,
|
|
menu: [
|
|
{ location: "file", group: "1_new", order: 3, label: "Open Document..." },
|
|
{
|
|
location: "navigate",
|
|
group: "2_picker",
|
|
order: 2,
|
|
label: "Document...",
|
|
},
|
|
],
|
|
run: async () => client.startPageNavigate("document"),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Navigate: Anything Picker",
|
|
run: async () => client.startPageNavigate("all"),
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: Find in Page",
|
|
key: "Ctrl-f",
|
|
mac: "Cmd-f",
|
|
requireEditor: "page",
|
|
menu: { location: "edit", group: "3_find", label: "Find in Page..." },
|
|
run: async () => {
|
|
openSearchPanel(view());
|
|
return false; // keep focus on search panel, not the editor
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Editor: New Window",
|
|
key: "Ctrl-n",
|
|
mac: "Cmd-n",
|
|
run: async () => {
|
|
globalThis.open(
|
|
location.href,
|
|
`rnd${Math.random()}`,
|
|
`width=${globalThis.innerWidth},heigh=${globalThis.innerHeight}`,
|
|
);
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Widgets: Refresh All",
|
|
requireEditor: "page",
|
|
run: async () => {
|
|
client.widgetCache.clearPrewarm();
|
|
broadcastReload();
|
|
return reloadAllWidgets();
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Baked Sections: Update",
|
|
key: "Ctrl-Shift-b",
|
|
mac: "Cmd-Shift-b",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
run: async () => {
|
|
await updateBakedSections(client);
|
|
},
|
|
});
|
|
hook.registerCommand({
|
|
name: "Baked Sections: Unbake Section At Cursor",
|
|
requireMode: "rw",
|
|
requireEditor: "page",
|
|
run: () => {
|
|
unbakeSectionAtCursor(client);
|
|
return Promise.resolve();
|
|
},
|
|
});
|
|
}
|