430 lines
14 KiB
TypeScript
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(),
|
|
);
|
|
}
|