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:
@@ -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
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user