1
0

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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1158 additions and 745 deletions

View File

@ -52,6 +52,7 @@ export {
EditorView,
highlightSpecialChars,
keymap,
placeholder,
runScopeHandlers,
ViewPlugin,
ViewUpdate,

View File

@ -3,7 +3,8 @@ import { ParsedQuery } from "$sb/lib/query.ts";
export type AppEvent =
| "page:click"
| "page:complete"
| "editor:complete"
| "minieditor:complete"
| "page:load"
| "editor:init"
| "plugs:loaded";
@ -36,3 +37,8 @@ export type PublishEvent = {
// Page name
name: string;
};
export type CompleteEvent = {
linePrefix: string;
pos: number;
};

View File

@ -95,12 +95,6 @@ export function insertAtCursor(text: string): Promise<void> {
return syscall("editor.insertAtCursor", text);
}
export function matchBefore(
re: string,
): Promise<{ from: number; to: number; text: string } | null> {
return syscall("editor.matchBefore", re);
}
export function dispatch(change: any): Promise<void> {
return syscall("editor.dispatch", change);
}
@ -122,10 +116,10 @@ export function enableReadOnlyMode(enabled: boolean) {
return syscall("editor.enableReadOnlyMode", enabled);
}
export function getVimEnabled(): Promise<boolean> {
return syscall("editor.getVimEnabled");
export function getUiOption(key: string): Promise<any> {
return syscall("editor.getUiOption", key);
}
export function setVimEnabled(enabled: boolean) {
return syscall("editor.setVimEnabled", enabled);
export function setUiOption(key: string, value: any): Promise<void> {
return syscall("editor.setUiOption", key, value);
}

View File

@ -1,6 +1,6 @@
import { collectNodesOfType } from "$sb/lib/tree.ts";
import { editor, index } from "$sb/silverbullet-syscall/mod.ts";
import type { IndexTreeEvent } from "$sb/app_event.ts";
import type { CompleteEvent, IndexTreeEvent } from "$sb/app_event.ts";
import { removeQueries } from "$sb/lib/query.ts";
// Key space
@ -21,13 +21,13 @@ export async function indexAnchors({ name: pageName, tree }: IndexTreeEvent) {
await index.batchSet(pageName, anchors);
}
export async function anchorComplete() {
const prefix = await editor.matchBefore("\\[\\[[^\\]@:]*@[\\w\\.\\-\\/]*");
if (!prefix) {
export async function anchorComplete(completeEvent: CompleteEvent) {
const match = /\[\[([^\]@:]*@[\w\.\-\/]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const [pageRefPrefix, anchorRef] = prefix.text.split("@");
let pageRef = pageRefPrefix.substring(2);
let [pageRef, anchorRef] = match[1].split("@");
if (!pageRef) {
pageRef = await editor.getCurrentPage();
}
@ -35,7 +35,7 @@ export async function anchorComplete() {
`a:${pageRef}:${anchorRef}`,
);
return {
from: prefix.from + pageRefPrefix.length + 1,
from: completeEvent.pos - anchorRef.length,
options: allAnchors.map((a) => ({
label: a.key.split(":")[2],
type: "anchor",

View File

@ -1,14 +1,16 @@
import { editor, system } from "$sb/silverbullet-syscall/mod.ts";
import { system } from "$sb/silverbullet-syscall/mod.ts";
import { CompleteEvent } from "../../plug-api/app_event.ts";
export async function commandComplete() {
const prefix = await editor.matchBefore("\\{\\[[^\\]]*");
if (!prefix) {
export async function commandComplete(completeEvent: CompleteEvent) {
const match = /\{\[([^\]]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const allCommands = await system.listCommands();
return {
from: prefix.from + 2,
from: completeEvent.pos - match[1].length,
options: Object.keys(allCommands).map((commandName) => ({
label: commandName,
type: "command",

View File

@ -26,6 +26,10 @@ functions:
path: "./editor.ts:toggleVimMode"
command:
name: "Editor: Toggle Vim Mode"
toggleDarkMode:
path: "./editor.ts:toggleDarkMode"
command:
name: "Editor: Toggle Dark Mode"
clearPageIndex:
path: "./page.ts:clearPageIndex"
@ -82,13 +86,13 @@ functions:
pageComplete:
path: "./page.ts:pageComplete"
events:
- page:complete
- editor:complete
# Commands
commandComplete:
path: "./command.ts:commandComplete"
events:
- page:complete
- editor:complete
# Item indexing
indexItem:
@ -126,7 +130,7 @@ functions:
tagComplete:
path: "./tags.ts:tagComplete"
events:
- page:complete
- editor:complete
tagProvider:
path: "./tags.ts:tagProvider"
events:
@ -140,7 +144,7 @@ functions:
anchorComplete:
path: "./anchor.ts:anchorComplete"
events:
- page:complete
- editor:complete
# Full text search
searchIndex:

View File

@ -17,13 +17,23 @@ export async function toggleReadOnlyMode() {
// Run on "editor:init"
export async function setEditorMode() {
if (await clientStore.get("vimMode")) {
await editor.setVimEnabled(true);
await editor.setUiOption("vimMode", true);
}
if (await clientStore.get("darkMode")) {
await editor.setUiOption("darkMode", true);
}
}
export async function toggleVimMode() {
let vimMode = await clientStore.get("vimMode");
vimMode = !vimMode;
await editor.setVimEnabled(vimMode);
await editor.setUiOption("vimMode", vimMode);
await clientStore.set("vimMode", vimMode);
}
export async function toggleDarkMode() {
let darkMode = await clientStore.get("darkMode");
darkMode = !darkMode;
await editor.setUiOption("darkMode", darkMode);
await clientStore.set("darkMode", darkMode);
}

View File

@ -1,4 +1,5 @@
import type {
CompleteEvent,
IndexEvent,
IndexTreeEvent,
QueryProviderEvent,
@ -101,10 +102,29 @@ export async function renamePage(cmdDef: any) {
return;
}
console.log("New name", newName);
if (newName.trim() === oldName.trim()) {
// Nothing to do here
console.log("Name unchanged, exiting");
return;
}
console.log("New name", newName);
try {
// This throws an error if the page does not exist, which we expect to be the case
await space.getPageMeta(newName);
// So when we get to this point, we error out
throw new Error(
`Page ${newName} already exists, cannot rename to existing page.`,
);
} catch (e: any) {
if (e.message.includes("not found")) {
// Expected not found error, so we can continue
} else {
await editor.flashNotification(e.message, "error");
throw e;
}
}
const pagesToUpdate = await getBackLinks(oldName);
console.log("All pages containing backlinks", pagesToUpdate);
@ -209,14 +229,14 @@ export async function reindexCommand() {
}
// Completion
export async function pageComplete() {
const prefix = await editor.matchBefore("\\[\\[[^\\]@:]*");
if (!prefix) {
export async function pageComplete(completeEvent: CompleteEvent) {
const match = /\[\[([^\]@:]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const allPages = await space.listPages();
return {
from: prefix.from + 2,
from: completeEvent.pos - match[1].length,
options: allPages.map((pageMeta) => ({
label: pageMeta.name,
boost: pageMeta.lastModified,

View File

@ -1,6 +1,10 @@
import { collectNodesOfType } from "$sb/lib/tree.ts";
import { editor, index } from "$sb/silverbullet-syscall/mod.ts";
import type { IndexTreeEvent, QueryProviderEvent } from "$sb/app_event.ts";
import type {
CompleteEvent,
IndexTreeEvent,
QueryProviderEvent,
} from "$sb/app_event.ts";
import { applyQuery, removeQueries } from "$sb/lib/query.ts";
// Key space
@ -18,15 +22,15 @@ export async function indexTags({ name, tree }: IndexTreeEvent) {
);
}
export async function tagComplete() {
const prefix = await editor.matchBefore("#[^#\\s]+");
// console.log("Running tag complete", prefix);
if (!prefix) {
export async function tagComplete(completeEvent: CompleteEvent) {
const match = /#[^#\s]+$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const allTags = await index.queryPrefix(`tag:${prefix.text}`);
const tagPrefix = match[0];
const allTags = await index.queryPrefix(`tag:${tagPrefix}`);
return {
from: prefix.from,
from: completeEvent.pos - tagPrefix.length,
options: allTags.map((tag) => ({
label: tag.value,
type: "tag",

View File

@ -1,20 +1,20 @@
import { events } from "$sb/plugos-syscall/mod.ts";
import { editor } from "$sb/silverbullet-syscall/mod.ts";
import { CompleteEvent } from "../../plug-api/app_event.ts";
export async function queryComplete() {
const prefix = await editor.matchBefore("#query [\\w\\-_]*");
if (prefix) {
const allEvents = await events.listEvents();
// console.log("All events", allEvents);
return {
from: prefix.from + "#query ".length,
options: allEvents
.filter((eventName) => eventName.startsWith("query:"))
.map((source) => ({
label: source.substring("query:".length),
})),
};
export async function queryComplete(completeEvent: CompleteEvent) {
const match = /#query ([\w\-_]+)*$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const allEvents = await events.listEvents();
return {
from: completeEvent.pos - match[1].length,
options: allEvents
.filter((eventName) => eventName.startsWith("query:"))
.map((source) => ({
label: source.substring("query:".length),
})),
};
}

View File

@ -22,7 +22,7 @@ functions:
queryComplete:
path: ./complete.ts:queryComplete
events:
- page:complete
- editor:complete
# Templates
insertQuery:

View File

@ -5,4 +5,5 @@ functions:
emojiCompleter:
path: "./emoji.ts:emojiCompleter"
events:
- page:complete
- editor:complete
- minieditor:complete

View File

@ -1,18 +1,20 @@
import emojis from "./emoji.json" assert { type: "json" };
import { editor } from "$sb/silverbullet-syscall/mod.ts";
import type { CompleteEvent } from "../../plug-api/app_event.ts";
export async function emojiCompleter() {
const prefix = await editor.matchBefore(":[\\w]+");
if (!prefix) {
export function emojiCompleter({ linePrefix, pos }: CompleteEvent) {
const match = /:([\w]+)$/.exec(linePrefix);
if (!match) {
return null;
}
const textPrefix = prefix.text.substring(1); // Cut off the initial :
const [fullMatch, emojiName] = match;
const filteredEmoji = emojis.filter(([_, shortcode]) =>
shortcode.includes(textPrefix)
shortcode.includes(emojiName)
);
return {
from: prefix.from,
from: pos - fullMatch.length,
filter: false,
options: filteredEmoji.map(([emoji, shortcode]) => ({
detail: shortcode,

View File

@ -174,6 +174,11 @@ function render(
},
body: cleanTags(mapRender(t.children!)),
};
case "Strikethrough":
return {
name: "del",
body: cleanTags(mapRender(t.children!)),
};
case "InlineCode":
return {
name: "tt",

View File

@ -46,6 +46,9 @@ export function directivePlugin() {
widgets.push(
Decoration.line({
class: "sb-directive-start sb-directive-start-outside",
attributes: {
spellcheck: "false",
},
}).range(
from,
),

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) {

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);
}}
>

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);
}
}
}

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"

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);
};
}, []);

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>

View File

@ -1,11 +1,7 @@
export * from "../common/deps.ts";
export {
Fragment,
h,
render as preactRender,
} from "https://esm.sh/preact@10.11.1";
export type { ComponentChildren } from "https://esm.sh/preact@10.11.1";
export { Fragment, h, render as preactRender } from "preact";
export type { ComponentChildren, FunctionalComponent } from "preact";
export {
useEffect,
useReducer,
@ -16,8 +12,6 @@ export {
export {
Book as BookIcon,
Home as HomeIcon,
Moon as MoonIcon,
Sun as SunIcon,
Terminal as TerminalIcon,
} from "https://esm.sh/preact-feather@4.2.1";
@ -30,4 +24,8 @@ export {
export { WebsocketProvider } from "https://esm.sh/y-websocket@1.4.5?external=yjs";
// Vim mode
export { vim } from "https://esm.sh/@replit/codemirror-vim@6.0.4?external=@codemirror/state,@codemirror/language,@codemirror/view,@codemirror/search,@codemirror/commands";
export {
getCM as vimGetCm,
Vim,
vim,
} from "https://esm.sh/@replit/codemirror-vim@6.0.4?external=@codemirror/state,@codemirror/language,@codemirror/view,@codemirror/search,@codemirror/commands";

View File

@ -2,9 +2,7 @@
import {
BookIcon,
HomeIcon,
MoonIcon,
preactRender,
SunIcon,
TerminalIcon,
useEffect,
useReducer,
@ -16,6 +14,7 @@ import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
CompletionContext,
completionKeymap,
CompletionResult,
drawSelection,
@ -86,7 +85,11 @@ import {
BuiltinSettings,
initialViewState,
} from "./types.ts";
import type { AppEvent, ClickEvent } from "../plug-api/app_event.ts";
import type {
AppEvent,
ClickEvent,
CompleteEvent,
} from "../plug-api/app_event.ts";
// UI Components
import { CommandPalette } from "./components/command_palette.tsx";
@ -109,7 +112,7 @@ import customMarkdownStyle from "./style.ts";
// Real-time collaboration
import { CollabState } from "./cm_plugins/collab.ts";
import { collabSyscalls } from "./syscalls/collab.ts";
import { vim } from "./deps.ts";
import { Vim, vim, vimGetCm } from "./deps.ts";
const frontMatterRegex = /^---\n(.*?)---\n/ms;
@ -146,7 +149,7 @@ export class Editor {
// Runtime state (that doesn't make sense in viewState)
collabState?: CollabState;
enableVimMode = false;
// enableVimMode = false;
constructor(
space: Space,
@ -188,6 +191,7 @@ export class Editor {
state: this.createEditorState("", ""),
parent: document.getElementById("sb-editor")!,
});
this.pageNavigator = new PathPageNavigator(
builtinSettings.indexPage,
urlPrefix,
@ -212,8 +216,8 @@ export class Editor {
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
globalThis.addEventListener("keydown", (ev) => {
if (!this.editorView?.hasFocus) {
if ((ev.target as any).closest(".cm-panel")) {
// In some CM panel, let's back out
if ((ev.target as any).closest(".cm-editor")) {
// In some cm element, let's back out
return;
}
if (runScopeHandlers(this.editorView!, ev, "editor")) {
@ -340,7 +344,10 @@ export class Editor {
this.saveTimeout = setTimeout(
() => {
if (this.currentPage) {
if (!this.viewState.unsavedChanges || this.viewState.forcedROMode) {
if (
!this.viewState.unsavedChanges ||
this.viewState.uiOptions.forcedROMode
) {
// No unsaved changes, or read-only mode, not gonna save
return resolve();
}
@ -458,8 +465,10 @@ export class Editor {
return EditorState.create({
doc: this.collabState ? this.collabState.ytext.toString() : text,
extensions: [
// Not using CM theming right now, but some extensions depend on the "dark" thing
EditorView.theme({}, { dark: this.viewState.uiOptions.darkMode }),
// Enable vim mode, or not
[...this.enableVimMode ? [vim({ status: true })] : []],
[...editor.viewState.uiOptions.vimMode ? [vim({ status: true })] : []],
// The uber markdown mode
markdown({
base: buildMarkdown(this.mdExtensions),
@ -485,7 +494,7 @@ export class Editor {
syntaxHighlighting(customMarkdownStyle(this.mdExtensions)),
autocompletion({
override: [
this.completer.bind(this),
this.editorComplete.bind(this),
this.slashCommandHook.slashCommandCompleter.bind(
this.slashCommandHook,
),
@ -494,8 +503,6 @@ export class Editor {
inlineImagesPlugin(),
highlightSpecialChars(),
history(),
// Enable vim mode
[...this.enableVimMode ? [vim()] : []],
drawSelection(),
dropCursor(),
indentOnInput(),
@ -518,6 +525,23 @@ export class Editor {
{ selector: "FrontMatter", class: "sb-frontmatter" },
]),
keymap.of([
{
key: "ArrowUp",
run: (view): boolean => {
// When going up while at the top of the document, focus the page name
const selection = view.state.selection.main;
const line = view.state.doc.lineAt(selection.from);
// Are we at the top of the document?
if (line.number === 1) {
// This can be done much nicer, but this is shorter, so... :)
document.querySelector<HTMLDivElement>(
"#sb-current-page .cm-content",
)!.focus();
return true;
}
return false;
},
},
...smartQuoteKeymap,
...closeBracketsKeymap,
...standardKeymap,
@ -548,7 +572,6 @@ export class Editor {
},
},
]),
EditorView.domEventHandlers({
click: (event: MouseEvent, view: EditorView) => {
safeRun(async () => {
@ -625,8 +648,20 @@ export class Editor {
}
}
async completer(): Promise<CompletionResult | null> {
const results = await this.dispatchAppEvent("page:complete");
// Code completion support
private async completeWithEvent(
context: CompletionContext,
eventName: AppEvent,
): Promise<CompletionResult | null> {
const editorState = context.state;
const selection = editorState.selection.main;
const line = editorState.doc.lineAt(selection.from);
const linePrefix = line.text.slice(0, selection.from - line.from);
const results = await this.dispatchAppEvent(eventName, {
linePrefix,
pos: selection.from,
} as CompleteEvent);
let actualResult = null;
for (const result of results) {
if (result) {
@ -642,6 +677,18 @@ export class Editor {
return actualResult;
}
editorComplete(
context: CompletionContext,
): Promise<CompletionResult | null> {
return this.completeWithEvent(context, "editor:complete");
}
miniEditorComplete(
context: CompletionContext,
): Promise<CompletionResult | null> {
return this.completeWithEvent(context, "minieditor:complete");
}
async reloadPage() {
console.log("Reloading page");
clearTimeout(this.saveTimeout);
@ -746,7 +793,7 @@ export class Editor {
contentDOM.setAttribute("autocapitalize", "on");
contentDOM.setAttribute(
"contenteditable",
readOnly || this.viewState.forcedROMode ? "false" : "true",
readOnly || this.viewState.uiOptions.forcedROMode ? "false" : "true",
);
}
@ -802,7 +849,22 @@ export class Editor {
viewState.perm === "ro",
);
}
}, [viewState.forcedROMode]);
}, [viewState.uiOptions.forcedROMode]);
useEffect(() => {
this.rebuildEditorState();
}, [viewState.uiOptions.vimMode]);
useEffect(() => {
document.documentElement.dataset.theme = viewState.uiOptions.darkMode
? "dark"
: "light";
}, [viewState.uiOptions.darkMode]);
useEffect(() => {
// Need to dispatch a resize event so that the top_bar can pick it up
globalThis.dispatchEvent(new Event("resize"));
}, [viewState.panels]);
return (
<>
@ -810,6 +872,9 @@ export class Editor {
<PageNavigator
allPages={viewState.allPages}
currentPage={this.currentPage}
completer={this.miniEditorComplete.bind(this)}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
onNavigate={(page) => {
dispatch({ type: "stop-navigate" });
editor.focus();
@ -840,6 +905,9 @@ export class Editor {
}
}}
commands={viewState.commands}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
completer={this.miniEditorComplete.bind(this)}
recentCommands={viewState.recentCommands}
/>
)}
@ -848,7 +916,10 @@ export class Editor {
label={viewState.filterBoxLabel}
placeholder={viewState.filterBoxPlaceHolder}
options={viewState.filterBoxOptions}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
allowNew={false}
completer={this.miniEditorComplete.bind(this)}
helpText={viewState.filterBoxHelpText}
onSelect={viewState.filterBoxOnSelect}
/>
@ -858,17 +929,24 @@ export class Editor {
notifications={viewState.notifications}
unsavedChanges={viewState.unsavedChanges}
isLoading={viewState.isLoading}
onRename={(newName) => {
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
completer={editor.miniEditorComplete.bind(editor)}
onRename={async (newName) => {
if (!newName) {
return editor.focus();
// Always move cursor to the start of the page
editor.editorView?.dispatch({
selection: { anchor: 0 },
});
editor.focus();
return;
}
console.log("Now renaming page to...", newName);
editor.system.loadedPlugs.get("core")!.invoke(
await editor.system.loadedPlugs.get("core")!.invoke(
"renamePage",
[{ page: newName }],
).then(() => {
editor.focus();
}).catch(console.error);
);
editor.focus();
}}
actionButtons={[
{
@ -892,20 +970,6 @@ export class Editor {
dispatch({ type: "show-palette", context: this.getContext() });
},
},
{
icon: localStorage.theme === "dark" ? SunIcon : MoonIcon,
description: "Toggle dark mode",
callback: () => {
if (localStorage.theme === "dark") {
localStorage.theme = "light";
} else {
localStorage.theme = "dark";
}
document.documentElement.dataset.theme = localStorage.theme;
// Trigger rerender: TERRIBLE IMPLEMENTATION
dispatch({ type: "page-saved" });
},
},
]}
rhs={!!viewState.panels.rhs.mode && (
<div
@ -985,9 +1049,4 @@ export class Editor {
});
this.rebuildEditorState();
}
setVimMode(vimMode: boolean) {
this.enableVimMode = vimMode;
this.rebuildEditorState();
}
}

View File

@ -139,10 +139,13 @@ export default function reducer(
filterBoxOptions: [],
filterBoxHelpText: "",
};
case "set-editor-ro":
case "set-ui-option":
return {
...state,
forcedROMode: action.enabled,
uiOptions: {
...state.uiOptions,
[action.key]: action.value,
},
};
}
return state;

View File

@ -1,4 +1,8 @@
.cm-editor {
.cm-focused {
outline: none !important;
}
#sb-main .cm-editor {
font-size: 18px;
--max-width: 800px;
height: 100%;
@ -18,9 +22,6 @@
max-width: 100%;
}
&.cm-focused {
outline: none !important;
}
// Indentation of follow-up lines
@mixin lineOverflow($baseIndent, $bulletIndent: 0) {
@ -74,19 +75,41 @@
}
&.sb-line-li-1.sb-line-li-2 {
@include lineOverflow(2);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
@include lineOverflow(4);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
@include lineOverflow(7);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
@include lineOverflow(6);
@include lineOverflow(10);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
@include lineOverflow(8);
@include lineOverflow(13);
}
}
.sb-line-ol.sb-line-ul {
// &.sb-line-li-1 {
// @include lineOverflow(1);
// }
&.sb-line-li-1.sb-line-li-2 {
@include lineOverflow(3);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
@include lineOverflow(6);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
@include lineOverflow(9);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
@include lineOverflow(12);
}
}
@ -196,9 +219,14 @@
margin-left: -1ch;
}
.cm-scroller {
// Give some breathing space at the bottom of the screen
padding-bottom: 20em;
}
}
.cm-scroller {
// Give some breathing space at the bottom of the screen
padding-bottom: 20em;
div:not(.cm-focused).cm-fat-cursor {
outline: none !important;
}

View File

@ -25,14 +25,6 @@
margin: 3px;
}
input {
background: transparent;
border: 0;
padding: 3px;
outline: 0;
font-size: 1em;
flex-grow: 100;
}
}
.sb-help-text {

View File

@ -77,26 +77,25 @@
}
}
.sb-current-page {
#sb-current-page {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-align: left;
display: block;
text-overflow: ellipsis;
// Action buttons width
margin-right: 140px;
}
input.sb-edit-page-name {
background: transparent;
white-space: nowrap;
text-align: left;
border: 0;
outline: none;
padding: 0;
width: 100%;
.cm-scroller {
font-family: var(--ui-font);
}
.cm-content {
padding: 0;
.cm-line {
padding: 0;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,29 +3,6 @@ import { EditorView, Transaction } from "../deps.ts";
import { SysCallMapping } from "../../plugos/system.ts";
import { FilterOption } from "../../common/types.ts";
type SyntaxNode = {
name: string;
text: string;
from: number;
to: number;
};
function ensureAnchor(expr: any, start: boolean) {
let _a;
const { source } = expr;
const addStart = start && source[0] != "^",
addEnd = source[source.length - 1] != "$";
if (!addStart && !addEnd) return expr;
return new RegExp(
`${addStart ? "^" : ""}(?:${source})${addEnd ? "$" : ""}`,
(_a = expr.flags) !== null && _a !== void 0
? _a
: expr.ignoreCase
? "i"
: "",
);
}
export function editorSyscalls(editor: Editor): SysCallMapping {
const syscalls: SysCallMapping = {
"editor.getCurrentPage": (): string => {
@ -155,26 +132,6 @@ export function editorSyscalls(editor: Editor): SysCallMapping {
},
});
},
"editor.matchBefore": (
_ctx,
regexp: string,
): { from: number; to: number; text: string } | null => {
const editorState = editor.editorView!.state;
const selection = editorState.selection.main;
const from = selection.from;
if (selection.empty) {
const line = editorState.doc.lineAt(from);
const start = Math.max(line.from, from - 250);
const str = line.text.slice(start - line.from, from - line.from);
const found = str.search(ensureAnchor(new RegExp(regexp), false));
// console.log("Line", line, start, str, new RegExp(regexp), found);
return found < 0
? null
: { from: start + found, to: from, text: str.slice(found) };
}
return null;
},
"editor.dispatch": (_ctx, change: Transaction) => {
editor.editorView!.dispatch(change);
},
@ -191,18 +148,16 @@ export function editorSyscalls(editor: Editor): SysCallMapping {
): boolean => {
return confirm(message);
},
"editor.enableReadOnlyMode": (_ctx, enabled: boolean) => {
"editor.getUiOption": (_ctx, key: string): any => {
return (editor.viewState.uiOptions as any)[key];
},
"editor.setUiOption": (_ctx, key: string, value: any) => {
editor.viewDispatch({
type: "set-editor-ro",
enabled,
type: "set-ui-option",
key,
value,
});
},
"editor.getVimEnabled": (): boolean => {
return editor.enableVimMode;
},
"editor.setVimEnabled": (_ctx, enabled: boolean) => {
editor.setVimMode(enabled);
},
};
return syscalls;

View File

@ -26,7 +26,6 @@ export type AppViewState = {
currentPage?: string;
editingPageName: boolean;
perm: EditorMode;
forcedROMode: boolean;
isLoading: boolean;
showPageNavigator: boolean;
showCommandPalette: boolean;
@ -37,6 +36,12 @@ export type AppViewState = {
notifications: Notification[];
recentCommands: Map<string, Date>;
uiOptions: {
vimMode: boolean;
darkMode: boolean;
forcedROMode: boolean;
};
showFilterBox: boolean;
filterBoxLabel: string;
filterBoxPlaceHolder: string;
@ -48,11 +53,15 @@ export type AppViewState = {
export const initialViewState: AppViewState = {
perm: "rw",
editingPageName: false,
forcedROMode: false,
isLoading: false,
showPageNavigator: false,
showCommandPalette: false,
unsavedChanges: false,
uiOptions: {
vimMode: false,
darkMode: false,
forcedROMode: false,
},
panels: {
lhs: {},
rhs: {},
@ -103,4 +112,4 @@ export type Action =
onSelect: (option: FilterOption | undefined) => void;
}
| { type: "hide-filterbox" }
| { type: "set-editor-ro"; enabled: boolean };
| { type: "set-ui-option"; key: string; value: any };

View File

@ -4,10 +4,18 @@ release.
---
## Next
* Changed styling for [[Frontmatter]], fenced code blocks and directives to avoid vertical jumping when moving the cursor around.
* Changed styling for [[Frontmatter]], fenced code blocks, and directives to avoid vertical jumping when moving the cursor around.
* Clicking the URL (inside of an image `![](url)` or link `[text](link)`) no longer navigates there, you need to click on the anchor text to navigate there now (this avoids a lot of weird behavior).
* Long page name in title now no longer overlap with action buttons
* Most areas where you enter text (e.g. the page name, page switcher, command palette and filter boxes) now use a CodeMirror editor. This means a few things:
1. If you have vim mode enabled, this mode will also be enabled there.
2. You can now use the emoji picker (`:party` etc.) in those places, in fact, any plug implementing the `minieditor:complete` event — right now just the emoji picker — will work.
* To keep the UI clean, the dark mode button has been removed, and has been replaced with a command: {[Editor: Toggle Dark Mode]}.
* Bug fix: Long page names in titles now no longer overlap with action buttons.
* Moving focus out of the page title now always performs a rename (previously this only happened when hitting `Enter`).
* Clicking on a page reference in a `render` clause (inside of a directive) now navigates there (use Alt-click to just move the cursor)
* Moving up from the first line of the page will now move your cursor to the page title for you to rename it, and moving down from there puts you back in the document.
* Note for plug authors: The (misnamed) `page:complete` event has been renamed to `editor:complete`. There's also a new `minieditor:complete` that's only used for "mini editors" (e.g. in the page switcher, command palette, and page name editor).
* Fixed various styling issues.
---