When `standalone` is set to true, this action button will only appear in "standalone PWA" mode (e.g. home-screen mobile apps, or installed PWA on desktop). Useful to e.g. only show forward and backward buttons. These forward and backward buttons are now also added to the default action button config.
635 lines
20 KiB
TypeScript
635 lines
20 KiB
TypeScript
import { Confirm, Prompt } from "./components/basic_modals.tsx";
|
|
import { CommandPalette } 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.MoreVertical,
|
|
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;
|
|
}
|
|
return {
|
|
icon: mdiIcon ? mdiIcon : featherIcon,
|
|
description: button.description || "",
|
|
dropdown: button.dropdown,
|
|
callback:
|
|
button.run ||
|
|
(() => {
|
|
this.flashNotification(
|
|
"actionButton did not specify a 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;
|
|
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());
|
|
}
|