Files
plainleaf/client/editor_ui.tsx
T
Zef Hemel ea2ad9a732 Add actionButton.define "command" attribute
This automatically triggers a command bound to an actionButton, and
notably also shows the keyboard shortcut connected to it (if any)
2026-04-16 14:23:54 +02:00

654 lines
21 KiB
TypeScript

import { Confirm, Prompt } from "./components/basic_modals.tsx";
import {
CommandPalette,
keyboardHint,
} from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { AnythingPicker } from "./components/anything_picker.tsx";
import { TopBar } from "./components/top_bar.tsx";
import reducer from "./reducer.ts";
import {
type Action,
type AppViewState,
initialViewState,
} from "./types/ui.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 "@silverbulletmd/silverbullet/lib/async";
import type {
FilterOption,
NotificationAction,
NotificationType,
} from "@silverbulletmd/silverbullet/type/client";
import { notificationDismissTimeouts } from "@silverbulletmd/silverbullet/type/client";
import {
getNameFromPath,
getPathExtension,
isMarkdownPath,
isValidName,
parseToRef,
type Path,
} from "@silverbulletmd/silverbullet/lib/ref";
export class MainUI {
viewState: AppViewState = initialViewState;
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();
void client.startCommandPalette();
}
});
globalThis.addEventListener("mouseup", (_) => {
setTimeout(() => {
client.editorView.dispatch({});
});
});
}
// Progress circle handling
private progressTimeout?: ReturnType<typeof setTimeout>;
viewDispatch: (action: Action) => void = () => {};
flashNotification(
message: string,
type: NotificationType = "info",
options?: {
timeout?: number;
actions?: NotificationAction[];
},
) {
const id = Math.floor(Math.random() * 1000000);
const dismiss = () => {
this.viewDispatch({ type: "dismiss-notification", id });
};
const persistent = options?.timeout === 0;
const actions = options?.actions?.map((action) => ({
name: action.name,
run: () => {
action.run();
dismiss();
},
}));
this.viewDispatch({
type: "show-notification",
notification: {
id,
type,
message,
date: new Date(),
actions,
persistent,
},
});
if (!persistent) {
const timeout = options?.timeout ?? notificationDismissTimeouts[type];
setTimeout(dismiss, timeout);
}
}
showProgress(progressPercentage?: number, progressType?: "sync" | "index") {
this.viewDispatch({
type: "set-progress",
progressPercentage,
progressType,
});
if (this.progressTimeout) {
clearTimeout(this.progressTimeout);
}
this.progressTimeout = setTimeout(() => {
this.viewDispatch({
type: "set-progress",
});
}, 5000);
}
filterBox(
label: string,
options: FilterOption[],
helpText = "",
placeHolder = "",
): Promise<FilterOption | undefined> {
return new Promise((resolve) => {
this.viewDispatch({
type: "show-filterbox",
label,
options,
placeHolder,
helpText,
onSelect: (option: any) => {
this.viewDispatch({ type: "hide-filterbox" });
this.client.focus();
resolve(option);
},
});
});
}
prompt(message: string, defaultValue = ""): Promise<string | undefined> {
return new Promise((resolve) => {
this.viewDispatch({
type: "show-prompt",
message,
defaultValue,
callback: (value: string | undefined) => {
this.viewDispatch({ type: "hide-prompt" });
this.client.focus();
resolve(value);
},
});
});
}
confirm(message: string): Promise<boolean> {
return new Promise((resolve) => {
this.viewDispatch({
type: "show-confirm",
message,
callback: (value: boolean) => {
this.viewDispatch({ type: "hide-confirm" });
this.client.focus();
resolve(value);
},
});
});
}
ViewComponent() {
const [viewState, dispatch] = useReducer(reducer, initialViewState);
this.viewState = viewState;
this.viewDispatch = dispatch;
const client = this.client;
useEffect(() => {
if (viewState.current) {
document.title =
(this.client.currentPageMeta()?.pageDecoration?.prefix ?? "") +
getNameFromPath(viewState.current.path);
}
}, [viewState.current]);
useEffect(() => {
void this.client.rebuildEditorState();
void this.client.dispatchAppEvent("editor:modeswitch");
}, [viewState.uiOptions.vimMode]);
useEffect(() => {
const updateTheme = () => {
const darkMode =
viewState.uiOptions.darkMode === undefined
? globalThis.matchMedia("(prefers-color-scheme: dark)").matches
: viewState.uiOptions.darkMode;
document.documentElement.dataset.theme = darkMode ? "dark" : "light";
if (this.client.contentManager.isDocumentEditor()) {
this.client.contentManager.documentEditor.updateTheme();
}
};
updateTheme();
if (viewState.uiOptions.darkMode === undefined) {
const mediaQuery = globalThis.matchMedia(
"(prefers-color-scheme: dark)",
);
mediaQuery.addEventListener("change", updateTheme);
return () => {
mediaQuery.removeEventListener("change", updateTheme);
};
}
}, [viewState.uiOptions.darkMode]);
useEffect(() => {
document.documentElement.dataset.markdownSyntaxRendering = viewState
.uiOptions.markdownSyntaxRendering
? "on"
: "off";
}, [viewState.uiOptions.markdownSyntaxRendering]);
useEffect(() => {
// Need to dispatch a resize event so that the top_bar can pick it up
globalThis.dispatchEvent(new Event("resize"));
}, [viewState.panels]);
const actionButtons = client.config.get<ActionButton[]>(
"actionButtons",
[],
);
return (
<>
{viewState.showPageNavigator && (
<AnythingPicker
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}
darkMode={viewState.uiOptions.darkMode}
onModeSwitch={(mode) => {
dispatch({ type: "stop-navigate" });
setTimeout(() => {
dispatch({ type: "start-navigate", mode });
});
}}
onNavigate={(name) => {
dispatch({ type: "stop-navigate" });
setTimeout(() => {
client.focus();
});
if (!name) {
return;
}
safeRun(async () => {
const ref = parseToRef(name);
// Check beforhand, because we don't want to allow any link
// stuff like #header here. The `!ref` check is just for
// Typescript
if (!isValidName(name) || !ref) {
// It's not a valid name so either, the user tried to create a
// page or we have an invalid file in the space. Names are
// only unique for files which follow our rules, so we are
// kind of in unknown territory now.
if (client.clientSystem.allKnownFiles.has(name)) {
// Try it as a document name === path
await this.promptDocumentOperation(
name as Path,
`'${name}' has an invalid name. You can now modify it`,
);
} else if (
client.clientSystem.allKnownFiles.has(`${name}.md`)
) {
// Try it as a page
await this.promptDocumentOperation(
`${name}.md`,
`'${name}.md' has an invalid name. You can now modify it`,
);
} else {
this.flashNotification(
`Couldn't create page ${name}, name is invalid`,
"error",
);
}
return;
}
if (
!isMarkdownPath(ref.path) &&
!Array.from(
client.clientSystem.documentEditorHook.documentEditors.values(),
).some(({ extensions }) =>
extensions.includes(getPathExtension(ref.path)),
)
) {
await this.promptDocumentOperation(
ref.path,
"This file cannot be edited, select your desired action.",
);
} else {
void client.navigate(ref);
}
});
}}
/>
)}
{viewState.showCommandPalette && (
<CommandPalette
onTrigger={(cmd) => {
safeRun(async () => {
dispatch({ type: "hide-palette" });
if (cmd) {
await this.client.registerCommandRun(cmd.name);
try {
const returnValue = await cmd.run!();
if (returnValue !== false) {
client.focus();
}
} catch (e: any) {
this.client.reportError(e, "Command invocation");
}
} else {
setTimeout(() => client.focus());
}
});
}}
commands={client.getCommandsByContext(viewState)}
darkMode={viewState.uiOptions.darkMode}
/>
)}
{viewState.showFilterBox && (
<FilterList
label={viewState.filterBoxLabel}
placeholder={viewState.filterBoxPlaceHolder}
options={viewState.filterBoxOptions}
darkMode={viewState.uiOptions.darkMode}
allowNew={false}
helpText={viewState.filterBoxHelpText}
onSelect={viewState.filterBoxOnSelect}
/>
)}
{viewState.showPrompt && (
<Prompt
message={viewState.promptMessage!}
defaultValue={viewState.promptDefaultValue}
darkMode={viewState.uiOptions.darkMode}
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 ? "" : getNameFromPath(viewState.current.path)
}
notifications={viewState.notifications}
onDismissNotification={(id) => {
dispatch({ type: "dismiss-notification", id });
}}
isOnline={viewState.isOnline}
unsavedChanges={viewState.unsavedChanges}
isLoading={viewState.isLoading}
darkMode={viewState.uiOptions.darkMode}
progressPercentage={viewState.progressPercentage}
progressType={viewState.progressType}
onClick={() => {
if (!client.contentManager.isDocumentEditor()) {
client.editorView.scrollDOM.scrollTop = 0;
}
}}
onRename={async (newName) => {
if (client.contentManager.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.Menu,
description: "Open Menu",
class: "expander",
callback: () => {
// Make the expander button open/close the menu via toggling the CSS class "open"
document
.querySelector("#sb-top .sb-actions.hamburger")
?.classList.toggle("open");
},
},
]
: []),
// Custom action buttons
...actionButtons
.filter(
(
// Filter out buttons without icons (invalid) and mobile buttons when not in mobile mode
button,
) =>
button.icon &&
(typeof button.mobile === "undefined" ||
button.mobile === viewState.isMobile) &&
(typeof button.standalone === "undefined" ||
button.standalone === viewState.isStandalone),
)
// Then ensure all buttons have a priority set (by default based on array index)
.map((button, index) => ({
...button,
priority: button.priority ?? actionButtons.length - index,
}))
.sort((a, b) => b.priority - a.priority)
.map((button) => {
const mdiIcon = (mdi as any)[kebabToCamel(button.icon)];
let featherIcon = (featherIcons as any)[
kebabToCamel(button.icon)
];
if (!featherIcon) {
featherIcon = featherIcons.HelpCircle;
}
// Build description with keyboard shortcut hint
let description = button.description || "";
if (button.command) {
const cmd = viewState.commands.get(button.command);
if (cmd) {
const hint = keyboardHint(cmd);
if (hint) {
description = description
? `${description} (${hint})`
: hint;
}
}
}
return {
icon: mdiIcon ? mdiIcon : featherIcon,
description,
dropdown: button.dropdown,
callback: button.command
? () => this.client.runCommandByName(button.command!)
: button.run ||
(() => {
this.flashNotification(
"actionButton did not specify a command or run() callback",
"error",
);
}),
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={
client.currentPageMeta()?.pageDecoration?.prefix ?? ""
}
cssClass={(client.currentPageMeta()?.pageDecoration?.cssClasses ?? [])
.join(" ")
.replaceAll(/[^a-zA-Z0-9-_ ]/g, "")}
mobileMenuStyle={viewState.isMobile
? client.config.get<string>(
"mobileMenuStyle",
"hamburger",
)
: undefined}
readOnly={
viewState.uiOptions.forcedROMode || client.bootConfig.readOnly
}
/>
<div id="sb-main">
{viewState.panels.lhs.mode !== undefined && (
<Panel config={viewState.panels.lhs} editor={client} />
)}
<div id="sb-editor" />
{viewState.panels.rhs.mode !== undefined && (
<Panel config={viewState.panels.rhs} editor={client} />
)}
</div>
{viewState.panels.modal.mode !== undefined && (
<div
className="sb-modal"
style={{ inset: `${viewState.panels.modal.mode}px` }}
>
<Panel config={viewState.panels.modal} editor={client} />
</div>
)}
{viewState.panels.bhs.mode !== undefined && (
<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);
}
async promptDocumentOperation(path: Path, msg: string) {
const options: string[] = ["View", "Delete", "Rename"];
const option = await this.filterBox(
"Modify",
options.map((x) => ({ name: x }) as FilterOption),
msg,
);
if (!option) return;
switch (option.name) {
case "View": {
await this.client.navigate({ path: path });
break;
}
case "Delete": {
if (
await this.confirm(
`Are you sure you would like delete ${getNameFromPath(path)}?`,
)
) {
if (isMarkdownPath(path)) {
await this.client.space.deletePage(getNameFromPath(path));
} else {
await this.client.space.deleteDocument(getNameFromPath(path));
}
}
break;
}
case "Rename": {
if (isMarkdownPath(path)) {
await this.client.clientSystem.system.invokeFunction(
"index.renamePageCommand",
[{ oldPage: getNameFromPath(path) }],
);
} else {
await this.client.clientSystem.system.invokeFunction(
"index.renameDocumentCommand",
[{ oldDocument: getNameFromPath(path) }],
);
}
break;
}
}
}
}
// TODO: Parking this here for now, this is very similar to the definition in top_bar.tsx
type ActionButton = {
icon: string;
description?: string;
command?: string;
mobile?: boolean;
standalone?: boolean;
dropdown?: boolean;
priority?: number;
run?: () => void;
};
function kebabToCamel(str: string) {
return str
.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
.replace(/^./, (g) => g.toUpperCase());
}