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:
parent
6897111cf9
commit
3545d00d46
@ -52,6 +52,7 @@ export {
|
||||
EditorView,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
placeholder,
|
||||
runScopeHandlers,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ functions:
|
||||
queryComplete:
|
||||
path: ./complete.ts:queryComplete
|
||||
events:
|
||||
- page:complete
|
||||
- editor:complete
|
||||
|
||||
# Templates
|
||||
insertQuery:
|
||||
|
@ -5,4 +5,5 @@ functions:
|
||||
emojiCompleter:
|
||||
path: "./emoji.ts:emojiCompleter"
|
||||
events:
|
||||
- page:complete
|
||||
- editor:complete
|
||||
- minieditor:complete
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -46,6 +46,9 @@ export function directivePlugin() {
|
||||
widgets.push(
|
||||
Decoration.line({
|
||||
class: "sb-directive-start sb-directive-start-outside",
|
||||
attributes: {
|
||||
spellcheck: "false",
|
||||
},
|
||||
}).range(
|
||||
from,
|
||||
),
|
||||
|
@ -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) {
|
||||
|
@ -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
web/components/mini_editor.tsx
Normal file
227
web/components/mini_editor.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
@ -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>
|
||||
|
16
web/deps.ts
16
web/deps.ts
@ -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";
|
||||
|
143
web/editor.tsx
143
web/editor.tsx
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
@ -25,14 +25,6 @@
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 3px;
|
||||
outline: 0;
|
||||
font-size: 1em;
|
||||
flex-grow: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.sb-help-text {
|
||||
|
@ -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
@ -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;
|
||||
|
15
web/types.ts
15
web/types.ts
@ -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 };
|
||||
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user