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 && ( 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 && ( { 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 && ( )} {viewState.showPrompt && ( { dispatch({ type: "hide-prompt" }); viewState.promptCallback!(value); }} /> )} {viewState.showConfirm && ( { dispatch({ type: "hide-confirm" }); viewState.confirmCallback!(value); }} /> )} { 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("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( "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 && (
)} lhs={!!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("mobileMenuStyle", "hamburger")} />
{!!viewState.panels.lhs.mode && ( )}
{!!viewState.panels.rhs.mode && ( )}
{!!viewState.panels.modal.mode && (
)} {!!viewState.panels.bhs.mode && (
)} ); } 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(), ); }