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; 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 { 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 { 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 { 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( "actionButtons", [], ); return ( <> {viewState.showPageNavigator && ( 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 && ( { 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 && ( )} {viewState.showPrompt && ( { dispatch({ type: "hide-prompt" }); viewState.promptCallback!(value); }} /> )} {viewState.showConfirm && ( { dispatch({ type: "hide-confirm" }); viewState.confirmCallback!(value); }} /> )} { 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("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 && (
) } lhs={ !!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( "mobileMenuStyle", "hamburger", ) : undefined} readOnly={ viewState.uiOptions.forcedROMode || client.bootConfig.readOnly } />
{viewState.panels.lhs.mode !== undefined && ( )}
{viewState.panels.rhs.mode !== undefined && ( )}
{viewState.panels.modal.mode !== undefined && (
)} {viewState.panels.bhs.mode !== undefined && (
)} ); } 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()); }