0.4.0 convex及界面修改
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
@@ -10,12 +10,17 @@ import { usePageLayoutStore } from "@/store/page-layout";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
|
||||
import { DocumentCommentsDrawer } from "@/components/editor/document-comments-drawer";
|
||||
import type { DocumentSnapshot } from "@/types/document";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
|
||||
import { CONTENT_LOADING_DELAY_MS } from "@/lib/constants";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -49,8 +54,14 @@ const defaultOptions: PageOptionsState = {
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
};
|
||||
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
|
||||
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
|
||||
|
||||
export function DocumentContent({
|
||||
documentId,
|
||||
@@ -74,6 +85,9 @@ export function DocumentContent({
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
const router = useRouter();
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
||||
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
const [content, setContent] = useState<unknown>(initialContent);
|
||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||
@@ -314,15 +328,221 @@ export function DocumentContent({
|
||||
[documentId, readOnly],
|
||||
);
|
||||
|
||||
const toggleOption = (key: keyof PageOptionsState) => {
|
||||
const toggleOption = useCallback(
|
||||
(key: BooleanPageOptionKey) => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const nextValue = !prev[key];
|
||||
const next = { ...prev, [key]: nextValue };
|
||||
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[persistOptions, readOnly],
|
||||
);
|
||||
|
||||
const setOptionPatch = useCallback(
|
||||
(patch: Partial<PageOptionsState>) => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
void persistOptions(patch);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[persistOptions, readOnly],
|
||||
);
|
||||
|
||||
const closeToc = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const nextValue = !prev[key];
|
||||
const next = { ...prev, [key]: nextValue };
|
||||
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
|
||||
if (!prev.showToc) return prev;
|
||||
const next = { ...prev, showToc: false };
|
||||
void persistOptions({ showToc: false });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, [persistOptions, readOnly]);
|
||||
|
||||
const handleSetPageFont = useCallback(
|
||||
(font: PageFont) => {
|
||||
setOptionPatch({ pageFont: font });
|
||||
},
|
||||
[setOptionPatch],
|
||||
);
|
||||
|
||||
const handleSetLayoutDensity = useCallback(
|
||||
(density: PageLayoutDensity) => {
|
||||
setOptionPatch({ layoutDensity: density });
|
||||
},
|
||||
[setOptionPatch],
|
||||
);
|
||||
|
||||
const handleSetEmbedDefaultToCursor = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
const blockId = editorBridge?.getCursorBlockId?.() ?? null;
|
||||
if (!blockId) {
|
||||
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
|
||||
return;
|
||||
}
|
||||
setOptionPatch({ embedDefaultBlockId: blockId });
|
||||
window.alert("已设置“嵌入默认位置”");
|
||||
}, [editorBridge, readOnly, setOptionPatch]);
|
||||
|
||||
const handleClearEmbedDefault = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
setOptionPatch({ embedDefaultBlockId: null });
|
||||
window.alert("已清除“嵌入默认位置”");
|
||||
}, [readOnly, setOptionPatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const root = pageRootRef.current;
|
||||
if (root) {
|
||||
const target = event.target;
|
||||
if (target && target instanceof Node && !root.contains(target)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
if (!ctrlOrMeta) return;
|
||||
if (!event.shiftKey) return;
|
||||
const key = String(event.key ?? "").toLowerCase();
|
||||
if (key === "l") {
|
||||
event.preventDefault();
|
||||
toggleOption("showToc");
|
||||
} else if (key === "c") {
|
||||
event.preventDefault();
|
||||
toggleOption("protectEditing");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [toggleOption]);
|
||||
|
||||
const buildDocumentUrl = useCallback((id: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/documents/${id}`;
|
||||
}
|
||||
return `${window.location.origin}/documents/${id}`;
|
||||
}, []);
|
||||
|
||||
const copyText = useCallback(async (text: string, successMessage: string) => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
window.alert(successMessage);
|
||||
return;
|
||||
} catch {
|
||||
// ignore and fallback
|
||||
}
|
||||
}
|
||||
window.prompt("复制失败,请手动复制内容", text);
|
||||
}, []);
|
||||
|
||||
const handleCopyPageLink = useCallback(
|
||||
async (includeTitle: boolean) => {
|
||||
const url = buildDocumentUrl(documentId);
|
||||
if (includeTitle) {
|
||||
const text = `${pageTitle || "无标题"}\n${url}`;
|
||||
await copyText(text, "标题 + 链接已复制");
|
||||
return;
|
||||
}
|
||||
await copyText(url, "页面链接已复制");
|
||||
},
|
||||
[buildDocumentUrl, copyText, documentId, pageTitle],
|
||||
);
|
||||
|
||||
const handleCopyPageReference = useCallback(
|
||||
async (mode: "inline" | "embed") => {
|
||||
const template = mode === "inline" ? `((${documentId}))` : `{{${documentId}}}`;
|
||||
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
|
||||
},
|
||||
[copyText, documentId],
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
editorBridge?.undo?.();
|
||||
}, [editorBridge]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
editorBridge?.redo?.();
|
||||
}, [editorBridge]);
|
||||
|
||||
const handleDeletePage = useCallback(async () => {
|
||||
if (readOnly) return;
|
||||
const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。");
|
||||
if (!ok) return;
|
||||
const resp = await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除失败");
|
||||
return;
|
||||
}
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}, [documentId, readOnly, router]);
|
||||
|
||||
const handleOpenMoveEmbed = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
openMoveEmbedPicker({
|
||||
workspaceId,
|
||||
defaultMode: "move",
|
||||
modes: ["move", "embed"],
|
||||
allowRoot: true,
|
||||
excludeIds: [documentId],
|
||||
onPick: async (mode, targetId) => {
|
||||
if (mode === "move") {
|
||||
const resp = await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, parentId: targetId ?? null, position: 999999 }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "移动失败");
|
||||
return;
|
||||
}
|
||||
window.alert("移动成功");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch("/api/documents/embed", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: documentId, targetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "嵌入失败");
|
||||
return;
|
||||
}
|
||||
window.alert("已嵌入到目标页面");
|
||||
},
|
||||
});
|
||||
}, [documentId, openMoveEmbedPicker, readOnly, router, workspaceId]);
|
||||
|
||||
const handleAddToTemplates = useCallback(async () => {
|
||||
if (readOnly) return;
|
||||
const ok = window.confirm("将该页面添加为模板?");
|
||||
if (!ok) return;
|
||||
const resp = await fetch("/api/documents/template", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, isTemplate: true }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "设置模板失败");
|
||||
return;
|
||||
}
|
||||
window.alert("已添加为模板");
|
||||
router.refresh();
|
||||
}, [documentId, readOnly, router]);
|
||||
|
||||
const formattedUpdatedAt = useMemo(() => {
|
||||
if (!updatedAt) return "";
|
||||
@@ -349,6 +569,16 @@ export function DocumentContent({
|
||||
URL.revokeObjectURL(url);
|
||||
}, [disableDownload, history, title]);
|
||||
|
||||
const pageRootClass = cn(
|
||||
"flex h-full overflow-hidden bg-wolai-bg",
|
||||
options.pageFont === "song" && "wolai-page-font-song",
|
||||
options.pageFont === "kai" && "wolai-page-font-kai",
|
||||
options.layoutDensity === "compact" && "wolai-page-density-compact",
|
||||
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
|
||||
options.smallText && "wolai-small-text",
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
|
||||
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
||||
latestBlocksRef.current = payload.blocks;
|
||||
setHistory((prev) => {
|
||||
@@ -398,7 +628,7 @@ export function DocumentContent({
|
||||
|
||||
return (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className="flex h-full overflow-hidden bg-wolai-bg" ref={pageRootRef}>
|
||||
<div className={pageRootClass} ref={pageRootRef}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="relative">
|
||||
@@ -411,6 +641,7 @@ export function DocumentContent({
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
||||
aria-label="页面标题"
|
||||
disabled={options.protectEditing || readOnly}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
</div>
|
||||
{readOnly ? (
|
||||
@@ -453,9 +684,15 @@ export function DocumentContent({
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
/>
|
||||
)}
|
||||
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
|
||||
<PageBacklinksPanel
|
||||
className="mt-10"
|
||||
workspaceId={workspaceId}
|
||||
documentId={documentId}
|
||||
defaultCollapsed={options.collapseBacklinks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showInspector && (
|
||||
@@ -464,8 +701,20 @@ export function DocumentContent({
|
||||
options={options}
|
||||
stats={stats}
|
||||
onToggle={toggleOption}
|
||||
onSetPageFont={handleSetPageFont}
|
||||
onSetLayoutDensity={handleSetLayoutDensity}
|
||||
onSetEmbedDefaultToCursor={handleSetEmbedDefaultToCursor}
|
||||
onClearEmbedDefault={handleClearEmbedDefault}
|
||||
onExport={handleExport}
|
||||
onOpenHistory={() => setHistoryOpen(true)}
|
||||
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onDeletePage={handleDeletePage}
|
||||
onOpenMoveEmbedPicker={handleOpenMoveEmbed}
|
||||
onCopyPageLink={handleCopyPageLink}
|
||||
onCopyPageReference={handleCopyPageReference}
|
||||
onAddToTemplates={handleAddToTemplates}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -475,6 +724,7 @@ export function DocumentContent({
|
||||
history={history}
|
||||
onRestore={handleRestoreSnapshot}
|
||||
/>
|
||||
<DocumentCommentsDrawer />
|
||||
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} />
|
||||
</ImagePickerProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user