Files
plainleaf/client/components/mini_editor.tsx
T
Zef Hemel efd827b721 Implements #510
Extracts almost all codemirror keyboard shortcuts into proper SB
commands so they can be rebound

Bonus: renders prettier versions of keyboard shortcuts on Mac
2026-04-17 16:45:11 +02:00

230 lines
6.1 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 { 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,
darkMode,
onBlur,
onEscape,
onKeyUp,
onKeyDown,
onEnter,
onChange,
focus,
editable,
}: {
text: string;
placeholderText?: string;
darkMode?: boolean;
focus?: boolean;
editable: boolean;
} & MiniEditorEvents) {
const editorDiv = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView>();
// 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]);
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() {
return EditorState.create({
doc: text,
extensions: [
EditorView.theme({}, { dark: darkMode }),
createCommandKeyBindings(globalThis.client, { miniEditor: true }),
[
...(editable
? []
: [EditorView.editable.of(false), EditorState.readOnly.of(true)]),
],
history(),
[...(placeholderText ? [placeholder(placeholderText)] : [])],
keymap.of([
{
key: "Enter",
run: (view) => {
onEnterHandler(view, false);
return true;
},
},
{
key: "Shift-Enter",
run: (view) => {
onEnterHandler(view, true);
return true;
},
},
{
key: "Escape",
run: (view) => {
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
return false;
}
if (callbacksRef.current!.onKeyUp) {
return callbacksRef.current!.onKeyUp(view, event);
}
return false;
},
blur: (_e, view) => {
onBlurHandler(view);
},
}),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate): void {
if (update.docChanged) {
callbacksRef.current!.onChange?.(update.state.sliceDoc());
}
}
},
),
],
});
// Avoid double triggering these events (may happen due to onkeypress vs onkeyup delay)
function onEnterHandler(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 onBlurHandler(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);
}
}
}