Files
plainleaf/client/components/mini_editor.tsx
T
rktjmpandGitHub 8754b685ba Ensure command keybindings work reliably in any vim-mode (#1821)
When the vim extension is enabled and in "normal" mode, some default
bindings collide with Silverbullets commands (eg: `ctrl-p` - Share,
`ctrl-o` - Open Document, `ctrl-e` - Export, `ctrl-g h` - Home). These
vim bindings are not always functional (they may appear to do nothing
depending on vims state). This gives the impression that Silverbullet is
bugged and wont trigger commands. In "insert" mode, most bindings are
passed through correctly with some exceptions (eg: `ctrl-o`).

By splitting the command keybindings into their own keymap extension and
loading it before the vim extension, we can ensure Silverbullet commands
always trigger.

This does mean some vim bindings are overshadowed, but if a binding
triggers a Silverbullet command, its generally obvious that *something*
happened and the user should be able to recognise why the vim binding
failed and then rebind the command or vim mapping.
2026-02-06 16:12:02 +01:00

262 lines
7.3 KiB
TypeScript

import { useEffect, useRef } from "preact/hooks";
import { history, historyKeymap, standardKeymap } from "@codemirror/commands";
import { EditorState } from "@codemirror/state";
import {
EditorView,
keymap,
placeholder,
ViewPlugin,
type ViewUpdate,
} from "@codemirror/view";
import { getCM as vimGetCm, Vim, vim } from "@replit/codemirror-vim";
import { createCommandKeyBindings } from "../codemirror/editor_state.ts";
type MiniEditorEvents = {
onEnter: (newText: string, shiftDown?: boolean) => void;
onEscape?: (newText: string) => void;
onBlur?: (newText: string) => void | Promise<void>;
onChange?: (newText: string) => void;
onKeyUp?: (view: EditorView, event: KeyboardEvent) => boolean;
onKeyDown?: (view: EditorView, event: KeyboardEvent) => boolean;
};
export function MiniEditor(
{
text,
placeholderText,
vimMode,
darkMode,
vimStartInInsertMode,
onBlur,
onEscape,
onKeyUp,
onKeyDown,
onEnter,
onChange,
focus,
editable,
}: {
text: string;
placeholderText?: string;
vimMode: boolean;
darkMode?: boolean;
vimStartInInsertMode?: boolean;
focus?: boolean;
editable: boolean;
} & MiniEditorEvents,
) {
const editorDiv = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView>();
const vimModeRef = useRef<string>("normal");
// TODO: This super duper ugly, but I don't know how to avoid it
// Due to how MiniCodeEditor is built, it captures the closures of all callback functions
// which results in them pointing to old state variables, to avoid this we do this...
const callbacksRef = useRef<MiniEditorEvents>();
useEffect(() => {
const currentEditorDiv = editorDiv.current;
if (currentEditorDiv) {
// console.log("Creating editor view");
const editorView = new EditorView({
state: buildEditorState(),
parent: currentEditorDiv,
});
editorViewRef.current = editorView;
const focusEditorView = editorView.focus.bind(editorView);
currentEditorDiv.addEventListener("focusin", focusEditorView);
if (focus) {
editorView.focus();
// Put cursor at the very end
editorView.dispatch({
selection: { anchor: text.length },
});
}
return () => {
currentEditorDiv.removeEventListener("focusin", focusEditorView);
if (editorViewRef.current) {
editorViewRef.current.destroy();
}
};
}
}, [editorDiv, placeholderText]);
useEffect(() => {
callbacksRef.current = {
onBlur,
onEnter,
onEscape,
onKeyUp,
onKeyDown,
onChange,
};
});
useEffect(() => {
if (editorViewRef.current) {
const currentEditorText = editorViewRef.current.state.sliceDoc();
if (currentEditorText !== text) {
editorViewRef.current.setState(buildEditorState());
editorViewRef.current.dispatch({
selection: { anchor: text.length },
});
}
}
}, [text, vimMode]);
let onBlurred = false, onEntered = false;
return (
<div
class="sb-mini-editor"
onKeyDownCapture={(e) => {
let stopPropagation = false;
if (callbacksRef.current!.onKeyDown) {
stopPropagation = callbacksRef.current!.onKeyDown(
editorViewRef.current!,
e,
);
}
if (stopPropagation) {
e.preventDefault();
e.stopPropagation();
}
}}
ref={editorDiv}
/>
);
function buildEditorState() {
// When vim mode is active, we need for CM to have created the new state
// and the subscribe to the vim mode's events
// This needs to happen in the next tick, so we wait a tick with setTimeout
if (vimMode) {
// Only applies to vim mode
setTimeout(() => {
const cm = vimGetCm(editorViewRef.current!)!;
cm.on("vim-mode-change", ({ mode }: { mode: string }) => {
vimModeRef.current = mode;
});
if (vimStartInInsertMode) {
Vim.handleKey(cm, "i", "+input");
}
});
}
return EditorState.create({
doc: text,
extensions: [
EditorView.theme({}, { dark: darkMode }),
// Insert command bindings before vim-mode to ensure they're available
// in normal mode. See editor_state.ts for more details.
createCommandKeyBindings(globalThis.client),
// Enable vim mode, or not
[...vimMode ? [vim()] : []],
[
...editable
? []
: [EditorView.editable.of(false), EditorState.readOnly.of(true)],
],
history(),
[...placeholderText ? [placeholder(placeholderText)] : []],
keymap.of([
{
key: "Enter",
run: (view) => {
onEnter(view, false);
return true;
},
},
{
key: "Shift-Enter",
run: (view) => {
onEnter(view, true);
return true;
},
},
{
key: "Escape",
run: (view) => {
callbacksRef.current!.onEscape &&
callbacksRef.current!.onEscape(view.state.sliceDoc());
return true;
},
},
...standardKeymap,
...historyKeymap,
]),
EditorView.domEventHandlers({
click: (e) => {
e.stopPropagation();
},
keyup: (event, view) => {
if (event.key === "Escape") {
// Esc should be handled by the keymap
return false;
}
if (event.key === "Enter") {
// Enter should be handled by the keymap, except when in Vim normal mode
// because then it's disabled
if (vimMode && vimModeRef.current === "normal") {
onEnter(view, event.shiftKey);
return true;
}
return false;
}
if (callbacksRef.current!.onKeyUp) {
return callbacksRef.current!.onKeyUp(view, event);
}
return false;
},
blur: (_e, view) => {
onBlur(view);
},
}),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate): void {
if (update.docChanged) {
callbacksRef.current!.onChange &&
callbacksRef.current!.onChange(update.state.sliceDoc());
}
}
},
),
],
});
// Avoid double triggering these events (may happen due to onkeypress vs onkeyup delay)
function onEnter(view: EditorView, shiftDown: boolean) {
if (onEntered) {
return;
}
onEntered = true;
callbacksRef.current!.onEnter(view.state.sliceDoc(), shiftDown);
// Event may occur again in 500ms
setTimeout(() => {
onEntered = false;
}, 500);
}
function onBlur(view: EditorView) {
if (onBlurred || onEntered) {
return;
}
onBlurred = true;
if (callbacksRef.current!.onBlur) {
Promise.resolve(callbacksRef.current!.onBlur(view.state.sliceDoc()))
.catch(() => {
// Reset the state
view.setState(buildEditorState());
});
}
// Event may occur again in 500ms
setTimeout(() => {
onBlurred = false;
}, 500);
}
}
}