3545d00d46
Replaces most editing components with CM components, enabling vim mode and completions everywhere Fixes #205 Fixes #221 Fixes #222 Fixes #223
228 lines
5.9 KiB
TypeScript
228 lines
5.9 KiB
TypeScript
import {
|
|
autocompletion,
|
|
closeBracketsKeymap,
|
|
CompletionContext,
|
|
completionKeymap,
|
|
CompletionResult,
|
|
EditorState,
|
|
EditorView,
|
|
highlightSpecialChars,
|
|
history,
|
|
historyKeymap,
|
|
keymap,
|
|
placeholder,
|
|
standardKeymap,
|
|
useEffect,
|
|
useRef,
|
|
ViewPlugin,
|
|
ViewUpdate,
|
|
Vim,
|
|
vim,
|
|
vimGetCm,
|
|
} from "../deps.ts";
|
|
|
|
type MiniEditorEvents = {
|
|
onEnter: (newText: string) => void;
|
|
onEscape?: (newText: string) => void;
|
|
onBlur?: (newText: string) => void | Promise<void>;
|
|
onChange?: (newText: string) => void;
|
|
onKeyUp?: (view: EditorView, event: KeyboardEvent) => boolean;
|
|
};
|
|
|
|
export function MiniEditor(
|
|
{
|
|
text,
|
|
placeholderText,
|
|
vimMode,
|
|
darkMode,
|
|
vimStartInInsertMode,
|
|
onBlur,
|
|
onEscape,
|
|
onKeyUp,
|
|
onEnter,
|
|
onChange,
|
|
focus,
|
|
completer,
|
|
}: {
|
|
text: string;
|
|
placeholderText?: string;
|
|
vimMode: boolean;
|
|
darkMode: boolean;
|
|
vimStartInInsertMode?: boolean;
|
|
focus?: boolean;
|
|
completer?: (
|
|
context: CompletionContext,
|
|
) => Promise<CompletionResult | null>;
|
|
} & 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(() => {
|
|
if (editorDiv.current) {
|
|
console.log("Creating editor view");
|
|
const editorView = new EditorView({
|
|
state: buildEditorState(),
|
|
parent: editorDiv.current!,
|
|
});
|
|
editorViewRef.current = editorView;
|
|
|
|
if (focus) {
|
|
editorView.focus();
|
|
}
|
|
|
|
return () => {
|
|
if (editorViewRef.current) {
|
|
editorViewRef.current.destroy();
|
|
}
|
|
};
|
|
}
|
|
}, [editorDiv]);
|
|
|
|
useEffect(() => {
|
|
callbacksRef.current = { onBlur, onEnter, onEscape, onKeyUp, onChange };
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (editorViewRef.current) {
|
|
editorViewRef.current.setState(buildEditorState());
|
|
editorViewRef.current.dispatch({
|
|
selection: { anchor: text.length },
|
|
});
|
|
}
|
|
}, [text, vimMode]);
|
|
|
|
let onBlurred = false, onEntered = false;
|
|
|
|
// console.log("Rendering editor");
|
|
|
|
return <div class="sb-mini-editor" 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");
|
|
}
|
|
});
|
|
}
|
|
return EditorState.create({
|
|
doc: text,
|
|
extensions: [
|
|
EditorView.theme({}, { dark: darkMode }),
|
|
// Enable vim mode, or not
|
|
[...vimMode ? [vim()] : []],
|
|
|
|
autocompletion({
|
|
override: completer ? [completer] : [],
|
|
}),
|
|
highlightSpecialChars(),
|
|
history(),
|
|
[...placeholderText ? [placeholder(placeholderText)] : []],
|
|
keymap.of([
|
|
{
|
|
key: "Enter",
|
|
run: (view) => {
|
|
onEnter(view);
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
key: "Escape",
|
|
run: (view) => {
|
|
callbacksRef.current!.onEscape &&
|
|
callbacksRef.current!.onEscape(view.state.sliceDoc());
|
|
return true;
|
|
},
|
|
},
|
|
...closeBracketsKeymap,
|
|
...standardKeymap,
|
|
...historyKeymap,
|
|
...completionKeymap,
|
|
]),
|
|
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);
|
|
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) {
|
|
if (onEntered) {
|
|
return;
|
|
}
|
|
onEntered = true;
|
|
callbacksRef.current!.onEnter(view.state.sliceDoc());
|
|
// 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((e) => {
|
|
// Reset the state
|
|
view.setState(buildEditorState());
|
|
});
|
|
}
|
|
// Event may occur again in 500ms
|
|
setTimeout(() => {
|
|
onBlurred = false;
|
|
}, 500);
|
|
}
|
|
}
|
|
}
|