Major mini editor refactoring (#225)
Replaces most editing components with CM components, enabling vim mode and completions everywhere Fixes #205 Fixes #221 Fixes #222 Fixes #223
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user