Files
plainleaf/web/editor_ui.tsx
T

430 lines
14 KiB
TypeScript

import { Confirm, Prompt } from "./components/basic_modals.tsx";
import { CommandPalette } from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { PageNavigator } from "./components/page_navigator.tsx";
import { TopBar } from "./components/top_bar.tsx";
import reducer from "./reducer.ts";
import { type Action, type AppViewState, initialViewState } from "./type.ts";
import * as featherIcons from "preact-feather";
import * as mdi from "./filtered_material_icons.ts";
import { h, render as preactRender } from "preact";
import { useEffect, useReducer } from "preact/hooks";
import { closeSearchPanel } from "@codemirror/search";
import { runScopeHandlers } from "@codemirror/view";
import type { Client } from "./client.ts";
import { Panel } from "./components/panel.tsx";
import { safeRun } from "../lib/async.ts";
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";
export class MainUI {
viewState: AppViewState = initialViewState;
viewDispatch: (action: Action) => void = () => {};
constructor(private client: Client) {
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
globalThis.addEventListener("keydown", (ev) => {
if (!client.editorView.hasFocus) {
const target = ev.target as HTMLElement;
if (target.className === "cm-textfield" && ev.key === "Escape") {
// Search panel is open, let's close it
console.log("Closing search panel");
closeSearchPanel(client.editorView);
return;
} else if (
target.className === "cm-textfield" ||
target.closest(".cm-content") || target.closest(".cm-vim-panel")
) {
// In some cm element, let's back out
return;
}
if (runScopeHandlers(client.editorView, ev, "editor")) {
ev.preventDefault();
}
}
});
globalThis.addEventListener("touchstart", (ev) => {
// Launch the page picker on a two-finger tap
if (ev.touches.length === 2) {
ev.stopPropagation();
ev.preventDefault();
client.startPageNavigate("page");
}
// Launch the command palette using a three-finger tap
if (ev.touches.length === 3) {
ev.stopPropagation();
ev.preventDefault();
this.viewDispatch({
type: "show-palette",
context: client.getContext(),
});
}
});
globalThis.addEventListener("mouseup", (_) => {
setTimeout(() => {
client.editorView.dispatch({});
});
});
}
ViewComponent() {
const [viewState, dispatch] = useReducer(reducer, initialViewState);
this.viewState = viewState;
this.viewDispatch = dispatch;
const client = this.client;
useEffect(() => {
if (viewState.current) {
document.title =
(viewState.current.meta?.pageDecoration?.prefix ?? "") +
viewState.current.path;
}
}, [viewState.current]);
useEffect(() => {
client.tweakEditorDOM(
client.editorView.contentDOM,
);
}, [viewState.uiOptions.forcedROMode]);
useEffect(() => {
this.client.rebuildEditorState();
this.client.dispatchAppEvent("editor:modeswitch");
}, [viewState.uiOptions.vimMode]);
useEffect(() => {
document.documentElement.dataset.theme = viewState.uiOptions.darkMode
? "dark"
: "light";
if (this.client.isDocumentEditor()) {
this.client.documentEditor.updateTheme();
}
}, [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 (
<>
{viewState.showPageNavigator && (
<PageNavigator
allDocuments={viewState.allDocuments}
allPages={viewState.allPages}
extensions={new Set(
Array.from(
client.clientSystem.documentEditorHook.documentEditors.values(),
).flatMap(({ extensions }) => extensions),
)}
currentPath={client.currentPath()}
mode={viewState.pageNavigatorMode}
completer={client.miniEditorComplete.bind(client)}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
onModeSwitch={(mode) => {
dispatch({ type: "stop-navigate" });
setTimeout(() => {
dispatch({ type: "start-navigate", mode });
});
}}
onNavigate={(name, type) => {
type = type ?? "page";
dispatch({ type: "stop-navigate" });
setTimeout(() => {
client.focus();
});
if (!name) return;
safeRun(async () => {
const documentMeta = viewState.allDocuments.find((document) =>
document.name === name
);
if (
type === "document" &&
!Array.from(
client.clientSystem.documentEditorHook.documentEditors
.values(),
).some(({ extensions }) =>
extensions.includes(documentMeta!.extension)
)
) {
const options: string[] = ["Delete", "Rename"];
const option = await client.filterBox(
"Modify",
options.map((x) => ({ name: x } as FilterOption)),
"There is no editor for this file type. Modify the selected document",
);
if (!option) return;
switch (option.name) {
case "Delete": {
if (
await client.confirm(
`Are you sure you want to delete ${name}?`,
)
) {
await client.space.deleteDocument(name);
client.flashNotification(
`Document ${name} has been deleted`,
);
}
return;
}
case "Rename": {
await client.clientSystem.system.invokeFunction(
"index.renameDocumentCommand",
[{ oldDocument: name }],
);
return;
}
}
} else {
await client.navigate({ kind: type, page: name });
}
});
}}
/>
)}
{viewState.showCommandPalette && (
<CommandPalette
onTrigger={(cmd) => {
dispatch({ type: "hide-palette" });
if (cmd) {
dispatch({ type: "command-run", command: cmd.command.name });
cmd
.run()
.catch((e: any) => {
console.error("Error running command", e.message);
})
.then((returnValue: any) => {
// Always be focusing the editor after running a command
if (returnValue !== false) {
client.focus();
}
});
} else {
setTimeout(() => client.focus());
}
}}
commands={client.getCommandsByContext(viewState)}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
completer={client.miniEditorComplete.bind(client)}
recentCommands={viewState.recentCommands}
config={client.config}
/>
)}
{viewState.showFilterBox && (
<FilterList
label={viewState.filterBoxLabel}
placeholder={viewState.filterBoxPlaceHolder}
options={viewState.filterBoxOptions}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
allowNew={false}
completer={client.miniEditorComplete.bind(client)}
helpText={viewState.filterBoxHelpText}
onSelect={viewState.filterBoxOnSelect}
/>
)}
{viewState.showPrompt && (
<Prompt
message={viewState.promptMessage!}
defaultValue={viewState.promptDefaultValue}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
completer={client.miniEditorComplete.bind(client)}
callback={(value) => {
dispatch({ type: "hide-prompt" });
viewState.promptCallback!(value);
}}
/>
)}
{viewState.showConfirm && (
<Confirm
message={viewState.confirmMessage!}
callback={(value) => {
dispatch({ type: "hide-confirm" });
viewState.confirmCallback!(value);
}}
/>
)}
<TopBar
pageName={viewState.current?.path || ""}
notifications={viewState.notifications}
syncFailures={viewState.syncFailures}
unsavedChanges={viewState.unsavedChanges}
isLoading={viewState.isLoading}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
progressPerc={viewState.progressPerc}
completer={client.miniEditorComplete.bind(client)}
onClick={() => {
if (!client.isDocumentEditor()) {
client.editorView.scrollDOM.scrollTop = 0;
}
}}
onRename={async (newName) => {
if (client.isDocumentEditor()) {
if (!newName) return;
console.log("Now renaming document to...", newName);
await client.clientSystem.system.invokeFunction(
"index.renameDocumentCommand",
[{ document: newName }],
);
} else {
if (!newName) {
// Always move cursor to the start of the page
client.editorView.dispatch({
selection: { anchor: 0 },
});
client.focus();
return;
}
console.log("Now renaming page to...", newName);
await client.clientSystem.system.invokeFunction(
"index.renamePageCommand",
[{ page: newName }],
);
client.focus();
}
}}
actionButtons={[
// Vertical menu button
...(viewState.isMobile &&
client.config.get<string>("mobileMenuStyle", "hamburger").includes(
"hamburger",
))
? [{
icon: featherIcons.MoreVertical,
description: "Open Menu",
class: "expander",
callback:
() => {/* nothing to do, menu opens on hover/mobile click */},
}]
: [],
// Custom action buttons
...client.config.get<ActionButton[]>(
"actionButtons",
defaultActionButtons,
).filter((
button,
) =>
(typeof button.mobile === "undefined") ||
(button.mobile === viewState.isMobile)
)
.map((button) => {
const mdiIcon = (mdi as any)[kebabToCamel(button.icon)];
let featherIcon =
(featherIcons as any)[kebabToCamel(button.icon)];
if (!featherIcon) {
featherIcon = featherIcons.HelpCircle;
}
return {
icon: mdiIcon ? mdiIcon : featherIcon,
description: button.description || "",
callback: () => {
client.runCommandByName(
button.command,
button.args || [],
);
},
href: "",
};
}),
]}
rhs={!!viewState.panels.rhs.mode && (
<div
className="panel"
style={{ flex: viewState.panels.rhs.mode }}
/>
)}
lhs={!!viewState.panels.lhs.mode && (
<div
className="panel"
style={{ flex: viewState.panels.lhs.mode }}
/>
)}
pageNamePrefix={viewState.current?.meta?.pageDecoration
?.prefix ??
""}
cssClass={viewState.current?.meta?.pageDecoration?.cssClasses
? viewState.current?.meta?.pageDecoration?.cssClasses
.join(" ").replaceAll(/[^a-zA-Z0-9-_ ]/g, "")
: ""}
mobileMenuStyle={client.config.get<string>("mobileMenuStyle", "hamburger")}
/>
<div id="sb-main">
{!!viewState.panels.lhs.mode && (
<Panel config={viewState.panels.lhs} editor={client} />
)}
<div id="sb-editor" />
{!!viewState.panels.rhs.mode && (
<Panel config={viewState.panels.rhs} editor={client} />
)}
</div>
{!!viewState.panels.modal.mode && (
<div
className="sb-modal"
style={{ inset: `${viewState.panels.modal.mode}px` }}
>
<Panel config={viewState.panels.modal} editor={client} />
</div>
)}
{!!viewState.panels.bhs.mode && (
<div className="sb-bhs">
<Panel config={viewState.panels.bhs} editor={client} />
</div>
)}
</>
);
}
render(container: Element) {
// const ViewComponent = this.ui.ViewComponent.bind(this.ui);
container.innerHTML = "";
preactRender(h(this.ViewComponent.bind(this), {}), container);
}
}
// TODO: Parking this here for now
type ActionButton = {
icon: string;
description?: string;
command: string;
args?: any[];
mobile?: boolean;
};
export const defaultActionButtons: ActionButton[] = [
{
icon: "Home",
description: "Go to the index page",
command: "Navigate: Home",
},
{
icon: "Book",
description: `Open page`,
command: "Navigate: Page Picker",
},
{
icon: "Terminal",
description: `Run command`,
command: "Open Command Palette",
},
];
function kebabToCamel(str: string) {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()).replace(
/^./,
(g) => g.toUpperCase(),
);
}