CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
Add a persistent system-content switch, translate the extension manager, and apply theme changes in place without reloading the browser UI. Co-Authored-By: Codex <noreply@anthropic.com>
970 lines
32 KiB
TypeScript
970 lines
32 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 {
|
|
PlainleafWorkspace,
|
|
plainleafPageLabel,
|
|
plainleafTaskReference,
|
|
visiblePlainleafPages,
|
|
type PlainleafAdvancedTool,
|
|
type PlainleafSearchResult,
|
|
type PlainleafSearchScope,
|
|
type PlainleafTask,
|
|
} from "./components/plainleaf_workspace.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, useRef } 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";
|
|
import { actionDescriptionZhCN, pageTitleZhCN } from "./lib/zh_cn.ts";
|
|
import { parsePageMetaLastModified } from "./lib/page_meta.ts";
|
|
|
|
export class MainUI {
|
|
viewState: AppViewState = initialViewState;
|
|
private plainleafPageTextCache = new Map<
|
|
string,
|
|
{ lastModified: string; text: string }
|
|
>();
|
|
|
|
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;
|
|
} else if (
|
|
target.closest('input, textarea, select, [contenteditable="true"]')
|
|
) {
|
|
// Focus is in a native form field (e.g. the top-bar page-name
|
|
// editor). Let the field own keys it handles natively — typing,
|
|
// caret navigation, and the standard clipboard/undo/select-all
|
|
// combos — but still forward genuine command shortcuts (e.g. Cmd-K)
|
|
// so they keep working from the field, like they did in the old
|
|
// CodeMirror mini-editor.
|
|
const cmd = ev.metaKey || ev.ctrlKey;
|
|
const key = ev.key.toLowerCase();
|
|
const fieldHandlesNatively =
|
|
!cmd ||
|
|
["a", "c", "v", "x", "z", "y"].includes(key) ||
|
|
[
|
|
"arrowleft",
|
|
"arrowright",
|
|
"arrowup",
|
|
"arrowdown",
|
|
"home",
|
|
"end",
|
|
"backspace",
|
|
"delete",
|
|
].includes(key);
|
|
if (fieldHandlesNatively) {
|
|
return;
|
|
}
|
|
// Otherwise fall through and forward the shortcut to the editor.
|
|
}
|
|
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 progressMap = new Map<
|
|
"index" | "sync",
|
|
{
|
|
percentage: number;
|
|
timeout: 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);
|
|
}
|
|
}
|
|
|
|
private dispatchProgressState() {
|
|
// Hide when nothing is active
|
|
if (this.progressMap.size === 0) {
|
|
this.viewDispatch({ type: "set-progress" });
|
|
return;
|
|
}
|
|
|
|
// Sync takes precedence over index so the indicator
|
|
// doesn't flip between the two when both streams are firing.
|
|
const progressType: "sync" | "index" = this.progressMap.has("sync")
|
|
? "sync"
|
|
: "index";
|
|
const entry = this.progressMap.get(progressType);
|
|
if (entry) {
|
|
this.viewDispatch({
|
|
type: "set-progress",
|
|
progressPercentage: entry.percentage,
|
|
progressType,
|
|
});
|
|
}
|
|
}
|
|
|
|
private removeProgressType(progressType: "index" | "sync") {
|
|
const entry = this.progressMap.get(progressType);
|
|
if (entry) {
|
|
clearTimeout(entry.timeout);
|
|
this.progressMap.delete(progressType);
|
|
}
|
|
}
|
|
|
|
showProgress(progressType: "sync" | "index", progressPercentage?: number) {
|
|
this.removeProgressType(progressType);
|
|
|
|
if (progressPercentage !== undefined) {
|
|
const timeout = setTimeout(() => {
|
|
this.removeProgressType(progressType);
|
|
this.dispatchProgressState();
|
|
}, 5000);
|
|
this.progressMap.set(progressType, {
|
|
percentage: progressPercentage,
|
|
timeout,
|
|
});
|
|
}
|
|
|
|
this.dispatchProgressState();
|
|
}
|
|
|
|
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,
|
|
options?: { destructive?: boolean },
|
|
): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
this.viewDispatch({
|
|
type: "show-confirm",
|
|
message,
|
|
destructive: options?.destructive,
|
|
callback: (value: boolean) => {
|
|
this.viewDispatch({ type: "hide-confirm" });
|
|
this.client.focus();
|
|
resolve(value);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
ViewComponent() {
|
|
const [viewState, dispatch] = useReducer(reducer, initialViewState);
|
|
const themeReady = useRef(false);
|
|
this.viewState = viewState;
|
|
this.viewDispatch = dispatch;
|
|
|
|
const client = this.client;
|
|
|
|
useEffect(() => {
|
|
if (viewState.current) {
|
|
const pageName =
|
|
(this.client.currentPageMeta()?.pageDecoration?.prefix ?? "") +
|
|
getNameFromPath(viewState.current.path);
|
|
document.title = pageTitleZhCN(
|
|
pageName,
|
|
this.client.bootConfig.indexPage,
|
|
);
|
|
}
|
|
}, [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();
|
|
}
|
|
|
|
if (themeReady.current) {
|
|
this.client.rebuildEditorState();
|
|
} else {
|
|
themeReady.current = true;
|
|
}
|
|
};
|
|
|
|
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",
|
|
[],
|
|
);
|
|
const plainleafActionButtons = actionButtons.filter(
|
|
(button) =>
|
|
![
|
|
"Navigate: Home",
|
|
"Navigate: Page Picker",
|
|
"Open Command Palette",
|
|
].includes(button.command ?? ""),
|
|
);
|
|
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}”的文件名不符合规则,你可以选择查看、删除或重命名。`,
|
|
);
|
|
} else if (
|
|
client.clientSystem.allKnownFiles.has(`${name}.md`)
|
|
) {
|
|
// Try it as a page
|
|
await this.promptDocumentOperation(
|
|
`${name}.md`,
|
|
`“${name}.md”的文件名不符合规则,你可以选择查看、删除或重命名。`,
|
|
);
|
|
} else {
|
|
this.flashNotification(
|
|
`无法新建页面“${name}”:名称不符合规则`,
|
|
"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,
|
|
"当前文件类型不能直接编辑,请选择要执行的操作。",
|
|
);
|
|
} else {
|
|
void client.open(ref);
|
|
}
|
|
});
|
|
}}
|
|
onNavigateRef={(ref) => {
|
|
dispatch({ type: "stop-navigate" });
|
|
setTimeout(() => {
|
|
client.focus();
|
|
});
|
|
// client.navigate resolves $-anchor refs to a page + position.
|
|
safeRun(async () => {
|
|
await 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!}
|
|
destructive={viewState.confirmDestructive}
|
|
callback={(value) => {
|
|
dispatch({ type: "hide-confirm" });
|
|
viewState.confirmCallback!(value);
|
|
}}
|
|
/>
|
|
)}
|
|
<PlainleafWorkspace
|
|
allPages={viewState.allPages}
|
|
currentPage={
|
|
viewState.current && isMarkdownPath(viewState.current.path)
|
|
? getNameFromPath(viewState.current.path)
|
|
: undefined
|
|
}
|
|
indexPage={client.bootConfig.indexPage}
|
|
isOnline={viewState.isOnline}
|
|
isReadOnly={
|
|
viewState.uiOptions.forcedROMode || client.bootConfig.readOnly
|
|
}
|
|
darkMode={
|
|
viewState.uiOptions.darkMode ??
|
|
globalThis.matchMedia("(prefers-color-scheme: dark)").matches
|
|
}
|
|
onOpenPage={(name) => {
|
|
const ref = parseToRef(name);
|
|
if (ref) void client.open(ref);
|
|
}}
|
|
onCreatePage={async (name) => {
|
|
if (!isValidName(name)) {
|
|
throw new Error("标题中包含不能用于笔记路径的字符");
|
|
}
|
|
const existing = viewState.allPages.find(
|
|
(page) => page.name === name && !page._isAspiring,
|
|
);
|
|
if (existing) {
|
|
const ref = parseToRef(existing.name);
|
|
if (ref) await client.open(ref);
|
|
return;
|
|
}
|
|
await client.space.writePage(name, "");
|
|
await client.updatePageListCache();
|
|
const ref = parseToRef(name);
|
|
if (ref) await client.open(ref);
|
|
}}
|
|
onOpenDaily={() => {
|
|
safeRun(() => client.runCommandByName("Journal: Today"));
|
|
}}
|
|
onSearchPages={async (
|
|
query: string,
|
|
scope: PlainleafSearchScope,
|
|
showSystemContent: boolean,
|
|
): Promise<PlainleafSearchResult[]> => {
|
|
const normalized = query.toLocaleLowerCase("zh-CN");
|
|
const visiblePages = visiblePlainleafPages(
|
|
viewState.allPages,
|
|
showSystemContent,
|
|
);
|
|
const results = new Map<string, PlainleafSearchResult>();
|
|
|
|
if (scope !== "content") {
|
|
for (const page of visiblePages) {
|
|
const haystack = [
|
|
page.name,
|
|
page.displayName,
|
|
page.description,
|
|
...(page.aliases ?? []),
|
|
...(page.tags ?? []),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLocaleLowerCase("zh-CN");
|
|
if (!haystack.includes(normalized)) continue;
|
|
results.set(page.name, {
|
|
page: page.name,
|
|
title: plainleafPageLabel(
|
|
page.name,
|
|
client.bootConfig.indexPage,
|
|
),
|
|
path: page.name,
|
|
excerpt: page.description ?? "",
|
|
updated: formatPlainleafUpdated(page),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (scope !== "title") {
|
|
const candidates = visiblePages.filter(
|
|
(page) => !results.has(page.name),
|
|
);
|
|
let nextIndex = 0;
|
|
const workerCount = Math.min(6, candidates.length);
|
|
await Promise.all(
|
|
Array.from({ length: workerCount }, async () => {
|
|
while (nextIndex < candidates.length) {
|
|
const page = candidates[nextIndex++];
|
|
const cached = this.plainleafPageTextCache.get(page.name);
|
|
const text =
|
|
cached?.lastModified === page.lastModified
|
|
? cached.text
|
|
: (await client.space.readPage(page.name)).text;
|
|
if (cached?.lastModified !== page.lastModified) {
|
|
this.plainleafPageTextCache.set(page.name, {
|
|
lastModified: page.lastModified,
|
|
text,
|
|
});
|
|
}
|
|
const matchIndex = text
|
|
.toLocaleLowerCase("zh-CN")
|
|
.indexOf(normalized);
|
|
if (matchIndex === -1) continue;
|
|
results.set(page.name, {
|
|
page: page.name,
|
|
title: plainleafPageLabel(
|
|
page.name,
|
|
client.bootConfig.indexPage,
|
|
),
|
|
path: page.name,
|
|
excerpt: plainleafSearchExcerpt(text, matchIndex),
|
|
updated: formatPlainleafUpdated(page),
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
return [...results.values()].slice(0, 60);
|
|
}}
|
|
onLoadTasks={() => client.queryLuaObjects<PlainleafTask>("task", {})}
|
|
onToggleTask={async (task) => {
|
|
await client.clientSystem.system.invokeFunction(
|
|
"index.updateTaskState",
|
|
[plainleafTaskReference(task), task.state, "x"],
|
|
);
|
|
}}
|
|
onOpenTask={(task) => {
|
|
const ref =
|
|
parseToRef(plainleafTaskReference(task)) ?? parseToRef(task.page);
|
|
if (ref) void client.open(ref);
|
|
}}
|
|
onOpenAdvancedTool={(tool: PlainleafAdvancedTool) => {
|
|
switch (tool) {
|
|
case "commands":
|
|
void client.startCommandPalette();
|
|
break;
|
|
case "graph":
|
|
safeRun(() =>
|
|
client.runCommandByName("Graph: Global Page Map"),
|
|
);
|
|
break;
|
|
case "libraries":
|
|
safeRun(() => client.runCommandByName("Libraries: Manager"));
|
|
break;
|
|
case "configuration":
|
|
safeRun(() => client.runCommandByName("Configuration: Open"));
|
|
break;
|
|
case "system-pages":
|
|
client.startPageNavigate("meta");
|
|
break;
|
|
}
|
|
}}
|
|
onToggleTheme={() => {
|
|
safeRun(() => client.runCommandByName("Editor: Toggle Dark Mode"));
|
|
}}
|
|
>
|
|
<TopBar
|
|
pageName={
|
|
!viewState.current
|
|
? ""
|
|
: isMarkdownPath(viewState.current.path)
|
|
? plainleafPageLabel(
|
|
getNameFromPath(viewState.current.path),
|
|
client.bootConfig.indexPage,
|
|
)
|
|
: getNameFromPath(viewState.current.path)
|
|
}
|
|
notifications={viewState.notifications}
|
|
onDismissNotification={(id) => {
|
|
dispatch({ type: "dismiss-notification", id });
|
|
}}
|
|
isOnline={viewState.isOnline}
|
|
unsavedChanges={viewState.unsavedChanges}
|
|
isLoading={viewState.isLoading}
|
|
progressPercentage={viewState.progressPercentage}
|
|
progressType={viewState.progressType}
|
|
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);
|
|
const currentName = getNameFromPath(client.currentPath());
|
|
const currentFolder = currentName.split("/").slice(0, -1);
|
|
const targetName =
|
|
newName.includes("/") || currentFolder.length === 0
|
|
? newName
|
|
: [...currentFolder, newName].join("/");
|
|
await client.clientSystem.system.invokeFunction(
|
|
"index.renamePageCommand",
|
|
[{ page: targetName }],
|
|
);
|
|
client.focus();
|
|
}
|
|
}}
|
|
actionButtons={[
|
|
// Vertical menu button
|
|
...(viewState.isMobile &&
|
|
client.config
|
|
.get<string>("mobileMenuStyle", "hamburger")
|
|
.includes("hamburger")
|
|
? [
|
|
{
|
|
icon: featherIcons.Menu,
|
|
description: "打开菜单",
|
|
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
|
|
...plainleafActionButtons
|
|
.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 ?? plainleafActionButtons.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 = actionDescriptionZhCN(
|
|
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(
|
|
"操作按钮没有配置命令或执行函数",
|
|
"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-backdrop">
|
|
<div
|
|
className="sb-modal"
|
|
style={{ inset: `${viewState.panels.modal.mode}px` }}
|
|
>
|
|
<Panel config={viewState.panels.modal} editor={client} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{viewState.panels.bhs.mode !== undefined && (
|
|
<div className="sb-bhs">
|
|
<Panel config={viewState.panels.bhs} editor={client} />
|
|
</div>
|
|
)}
|
|
</PlainleafWorkspace>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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: FilterOption[] = [
|
|
{ name: "查看", meta: { action: "view" } },
|
|
{ name: "删除", meta: { action: "delete" } },
|
|
{ name: "重命名", meta: { action: "rename" } },
|
|
];
|
|
|
|
const option = await this.filterBox("文件操作", options, msg);
|
|
if (!option) return;
|
|
|
|
switch (option.meta.action) {
|
|
case "view": {
|
|
await this.client.open({ path: path });
|
|
break;
|
|
}
|
|
case "delete": {
|
|
if (
|
|
await this.confirm(
|
|
`确定要删除“${getNameFromPath(path)}”吗?此操作无法撤销。`,
|
|
{ destructive: true },
|
|
)
|
|
) {
|
|
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 formatPlainleafUpdated(page: {
|
|
lastOpened?: number;
|
|
lastModified: string;
|
|
}): string {
|
|
const timestamp =
|
|
page.lastOpened ?? parsePageMetaLastModified(page.lastModified);
|
|
if (!timestamp) return "";
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
if (date.toDateString() === now.toDateString()) {
|
|
return date.toLocaleTimeString("zh-CN", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
return date.toLocaleDateString("zh-CN", {
|
|
month: "numeric",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
function plainleafSearchExcerpt(text: string, matchIndex: number): string {
|
|
const start = Math.max(0, matchIndex - 55);
|
|
const end = Math.min(text.length, matchIndex + 105);
|
|
const excerpt = text
|
|
.slice(start, end)
|
|
.replaceAll(/[`*_#>[\]{}]/g, "")
|
|
.replaceAll(/\s+/g, " ")
|
|
.trim();
|
|
return `${start > 0 ? "..." : ""}${excerpt}${end < text.length ? "..." : ""}`;
|
|
}
|
|
|
|
function kebabToCamel(str: string) {
|
|
return str
|
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
|
|
.replace(/^./, (g) => g.toUpperCase());
|
|
}
|