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:
Zef Hemel
2022-12-21 14:55:24 +01:00
committed by GitHub
parent 6897111cf9
commit 3545d00d46
31 changed files with 1158 additions and 745 deletions
+12 -3
View File
@@ -1,6 +1,6 @@
import { isMacLike } from "../../common/util.ts";
import { FilterList } from "./filter.tsx";
import { TerminalIcon } from "../deps.ts";
import { CompletionContext, CompletionResult, TerminalIcon } from "../deps.ts";
import { AppCommand } from "../hooks/command.ts";
import { FilterOption } from "../../common/types.ts";
@@ -8,14 +8,20 @@ export function CommandPalette({
commands,
recentCommands,
onTrigger,
vimMode,
darkMode,
completer,
}: {
commands: Map<string, AppCommand>;
recentCommands: Map<string, Date>;
vimMode: boolean;
darkMode: boolean;
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
onTrigger: (command: AppCommand | undefined) => void;
}) {
let options: FilterOption[] = [];
const options: FilterOption[] = [];
const isMac = isMacLike();
for (let [name, def] of commands.entries()) {
for (const [name, def] of commands.entries()) {
options.push({
name: name,
hint: isMac && def.command.mac ? def.command.mac : def.command.key,
@@ -31,6 +37,9 @@ export function CommandPalette({
options={options}
allowNew={false}
icon={TerminalIcon}
completer={completer}
vimMode={vimMode}
darkMode={darkMode}
helpText="Start typing the command name to filter results, press <code>Return</code> to run."
onSelect={(opt) => {
if (opt) {
+51 -48
View File
@@ -1,8 +1,15 @@
import { useEffect, useRef, useState } from "../deps.ts";
import {
CompletionContext,
CompletionResult,
useEffect,
useRef,
useState,
} from "../deps.ts";
import { FilterOption } from "../../common/types.ts";
import fuzzysort from "https://esm.sh/fuzzysort@2.0.1";
import { FunctionalComponent } from "https://esm.sh/v99/preact@10.11.3/src/index";
import { FeatherProps } from "https://esm.sh/v99/preact-feather@4.2.1/dist/types";
import { MiniEditor } from "./mini_editor.tsx";
function magicSorter(a: FilterOption, b: FilterOption): number {
if (a.orderId && b.orderId) {
@@ -56,6 +63,9 @@ export function FilterList({
label,
onSelect,
onKeyPress,
completer,
vimMode,
darkMode,
allowNew = false,
helpText = "",
completePrefix,
@@ -67,13 +77,15 @@ export function FilterList({
label: string;
onKeyPress?: (key: string, currentText: string) => void;
onSelect: (option: FilterOption | undefined) => void;
vimMode: boolean;
darkMode: boolean;
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
allowNew?: boolean;
completePrefix?: string;
helpText: string;
newHint?: string;
icon?: FunctionalComponent<FeatherProps>;
}) {
const searchBoxRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [matchingOptions, setMatchingOptions] = useState(
fuzzySorter("", options),
@@ -93,7 +105,7 @@ export function FilterList({
}
setMatchingOptions(results);
setText(originalPhrase);
// setText(originalPhrase);
setSelectionOption(0);
}
@@ -101,12 +113,9 @@ export function FilterList({
updateFilter(text);
}, [options]);
useEffect(() => {
searchBoxRef.current!.focus();
}, []);
useEffect(() => {
function closer() {
console.log("Invoking closer");
onSelect(undefined);
}
@@ -117,73 +126,67 @@ export function FilterList({
};
}, []);
let exiting = false;
const returnEl = (
<div className="sb-filter-wrapper">
<div className="sb-filter-box">
<div className="sb-header">
<label>{label}</label>
<input
type="text"
value={text}
placeholder={placeholder}
ref={searchBoxRef}
onBlur={(e) => {
if (!exiting && searchBoxRef.current) {
searchBoxRef.current.focus();
}
<MiniEditor
text={text}
vimMode={vimMode}
vimStartInInsertMode={true}
focus={true}
darkMode={darkMode}
completer={completer}
placeholderText={placeholder}
onEnter={() => {
onSelect(matchingOptions[selectedOption]);
return true;
}}
onKeyUp={(e) => {
onEscape={() => {
onSelect(undefined);
}}
onChange={(text) => {
updateFilter(text);
}}
onKeyUp={(view, e) => {
if (onKeyPress) {
onKeyPress(e.key, text);
onKeyPress(e.key, view.state.sliceDoc());
}
switch (e.key) {
case "ArrowUp":
setSelectionOption(Math.max(0, selectedOption - 1));
break;
return true;
case "ArrowDown":
setSelectionOption(
Math.min(matchingOptions.length - 1, selectedOption + 1),
);
break;
case "Enter":
exiting = true;
onSelect(matchingOptions[selectedOption]);
e.preventDefault();
break;
return true;
case "PageUp":
setSelectionOption(Math.max(0, selectedOption - 5));
break;
return true;
case "PageDown":
setSelectionOption(Math.max(0, selectedOption + 5));
break;
return true;
case "Home":
setSelectionOption(0);
break;
return true;
case "End":
setSelectionOption(matchingOptions.length - 1);
break;
case "Escape":
exiting = true;
onSelect(undefined);
e.preventDefault();
break;
case " ":
if (completePrefix && !text) {
return true;
case " ": {
const text = view.state.sliceDoc();
if (completePrefix && text === " ") {
console.log("Doing the complete thing");
setText(completePrefix);
updateFilter(completePrefix);
e.preventDefault();
return true;
}
break;
default:
updateFilter((e.target as any).value);
}
}
e.stopPropagation();
return false;
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
<div
@@ -204,8 +207,8 @@ export function FilterList({
setSelectionOption(idx);
}}
onClick={(e) => {
e.preventDefault();
exiting = true;
console.log("Selecting", option);
e.stopPropagation();
onSelect(option);
}}
>
+227
View File
@@ -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);
}
}
}
+10 -1
View File
@@ -1,13 +1,20 @@
import { FilterList } from "./filter.tsx";
import { FilterOption, PageMeta } from "../../common/types.ts";
import { CompletionContext, CompletionResult } from "../deps.ts";
export function PageNavigator({
allPages,
onNavigate,
completer,
vimMode,
darkMode,
currentPage,
}: {
allPages: Set<PageMeta>;
vimMode: boolean;
darkMode: boolean;
onNavigate: (page: string | undefined) => void;
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
currentPage?: string;
}) {
const options: FilterOption[] = [];
@@ -40,7 +47,9 @@ export function PageNavigator({
placeholder="Page"
label="Open"
options={options}
// icon={faFileLines}
vimMode={vimMode}
darkMode={darkMode}
completer={completer}
allowNew={true}
helpText="Start typing the page name to filter results, press <code>Return</code> to open."
newHint="Create page"
-2
View File
@@ -83,10 +83,8 @@ export function Panel({
editor.dispatchAppEvent(data.name, ...data.args);
}
};
console.log("Registering event handler");
globalThis.addEventListener("message", messageListener);
return () => {
console.log("Unregistering event handler");
globalThis.removeEventListener("message", messageListener);
};
}, []);
+74 -34
View File
@@ -1,15 +1,13 @@
import { useRef } from "../deps.ts";
import { ComponentChildren } from "../deps.ts";
import {
CompletionContext,
CompletionResult,
useEffect,
useRef,
} from "../deps.ts";
import type { ComponentChildren, FunctionalComponent } from "../deps.ts";
import { Notification } from "../types.ts";
import { FunctionalComponent } from "https://esm.sh/v99/preact@10.11.1/src/index";
import { FeatherProps } from "https://esm.sh/v99/preact-feather@4.2.1/dist/types";
function prettyName(s: string | undefined): string {
if (!s) {
return "";
}
return s.replaceAll("/", " / ");
}
import { MiniEditor } from "./mini_editor.tsx";
export type ActionButton = {
icon: FunctionalComponent<FeatherProps>;
@@ -24,6 +22,9 @@ export function TopBar({
notifications,
onRename,
actionButtons,
darkMode,
vimMode,
completer,
lhs,
rhs,
}: {
@@ -31,7 +32,10 @@ export function TopBar({
unsavedChanges: boolean;
isLoading: boolean;
notifications: Notification[];
onRename: (newName?: string) => void;
darkMode: boolean;
vimMode: boolean;
onRename: (newName?: string) => Promise<void>;
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
actionButtons: ActionButton[];
lhs?: ComponentChildren;
rhs?: ComponentChildren;
@@ -39,6 +43,31 @@ export function TopBar({
// const [theme, setTheme] = useState<string>(localStorage.theme ?? "light");
const inputRef = useRef<HTMLInputElement>(null);
// Another one of my less proud moments:
// Somehow I cannot seem to proerply limit the width of the page name, so I'm doing
// it this way. If you have a better way to do this, please let me know!
useEffect(() => {
function resizeHandler() {
const currentPageElement = document.getElementById("sb-current-page");
if (currentPageElement) {
// Temporarily make it very narrow to give the parent space
currentPageElement.style.width = "10px";
const innerDiv = currentPageElement.parentElement!.parentElement!;
// Then calculate a new width
currentPageElement.style.width = `${
Math.min(650, innerDiv.clientWidth - 150)
}px`;
}
}
globalThis.addEventListener("resize", resizeHandler);
// Stop listening on unmount
return () => {
globalThis.removeEventListener("resize", resizeHandler);
};
}, []);
return (
<div id="sb-top">
{lhs}
@@ -46,32 +75,43 @@ export function TopBar({
<div className="inner">
<div className="wrapper">
<span
className={`sb-current-page ${
isLoading
? "sb-loading"
: unsavedChanges
? "sb-unsaved"
: "sb-saved"
}`}
id="sb-current-page"
className={isLoading
? "sb-loading"
: unsavedChanges
? "sb-unsaved"
: "sb-saved"}
>
<input
type="text"
ref={inputRef}
value={pageName}
className="sb-edit-page-name"
onBlur={(e) => {
(e.target as any).value = pageName;
<MiniEditor
text={pageName ?? ""}
vimMode={vimMode}
darkMode={darkMode}
onBlur={(newName) => {
if (newName !== pageName) {
return onRename(newName);
} else {
return onRename();
}
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
const newName = (e.target as any).value;
onRename(newName);
}
if (e.key === "Escape") {
onRename();
onKeyUp={(view, event) => {
// When moving cursor down, cancel and move back to editor
if (event.key === "ArrowDown") {
const parent =
(event.target as any).parentElement.parentElement;
// Unless we have autocomplete open
if (
parent.getElementsByClassName("cm-tooltip-autocomplete")
.length === 0
) {
onRename();
return true;
}
}
return false;
}}
completer={completer}
onEnter={(newName) => {
onRename(newName);
}}
/>
</span>