Files
mnote/wolai-frontend/src/components/editor/document-content.tsx
T

260 lines
8.5 KiB
TypeScript
Raw Normal View History

2025-11-23 10:55:04 +08:00
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from "react";
import type { DocumentStats, PageOptionsState } 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";
import { usePageLayoutStore } from "@/store/page-layout";
import type { Json } from "@/types/supabase";
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">编辑器加载中...</div>
),
},
);
export interface DocumentContentProps {
documentId: string;
workspaceId: string;
title: string | null;
updatedAt: string | null;
initialContent: unknown;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
}
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
export function DocumentContent({
documentId,
workspaceId,
title,
updatedAt,
initialContent,
initialOptions,
initialStats,
}: DocumentContentProps) {
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const showInspector = usePageLayoutStore((state) => state.showInspector);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setPageTitle(title ?? "无标题");
}, [title]);
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
/* eslint-enable react-hooks/set-state-in-effect */
const persistTitle = useCallback(
async (nextTitle: string) => {
const payload = nextTitle.trim() || "无标题";
await fetch("/api/documents/title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, title: payload }),
});
},
[documentId],
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
void persistTitle(value);
}, 600);
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setPageTitle(value);
debouncedPersistTitle(value);
};
const handleTitleBlur = () => {
void persistTitle(pageTitle);
};
const handleTitleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
};
const persistOptions = useCallback(
async (patch: Partial<PageOptionsState>) => {
try {
const response = await fetch("/api/documents/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, options: patch }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
console.error(payload?.error ?? "更新页面选项失败");
}
} catch (error) {
console.error(error);
}
},
[documentId],
);
const toggleOption = (key: keyof PageOptionsState) => {
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
});
};
const formattedUpdatedAt = useMemo(() => {
if (!updatedAt) return "";
return new Date(updatedAt).toLocaleString();
}, [updatedAt]);
const handleExport = useCallback(() => {
const latest = history[0];
if (!latest) {
window.alert("暂无可导出的内容");
return;
}
const payload = JSON.stringify(latest.blocks, null, 2);
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`;
anchor.click();
URL.revokeObjectURL(url);
}, [history, title]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setHistory((prev) => {
const now = Date.now();
if (prev.length > 0 && now - prev[0].timestamp < 4000) {
return prev;
}
const snapshot: DocumentSnapshot = {
id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: now,
blocks: payload.blocks,
stats: payload.stats,
};
return [snapshot, ...prev].slice(0, 15);
});
}, []);
const persistStatsRequest = useCallback((next: DocumentStats) => {
void fetch("/api/documents/stats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, stats: next }),
}).catch((error) => console.error(error));
}, [documentId]);
const persistStats = useDebouncedCallback(persistStatsRequest, 1500);
const handleStatsChange = useCallback(
(nextStats: DocumentStats) => {
setStats(nextStats);
persistStats(nextStats);
},
[persistStats],
);
const handleRestoreSnapshot = useCallback(
(snapshot: DocumentSnapshot) => {
if (!editorBridge) {
window.alert("编辑器尚未准备好,无法恢复历史版本");
return;
}
editorBridge.replaceWithSnapshot(snapshot.blocks);
setHistoryOpen(false);
},
[editorBridge],
);
return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className="flex h-full overflow-hidden bg-white">
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-[#f5f5f5] px-12 pb-6 pt-8">
<div className="relative">
<input
value={pageTitle}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
placeholder="无标题"
className="w-full border-none bg-transparent text-3xl font-semibold text-[#333333] outline-none focus:ring-0"
aria-label="页面标题"
disabled={options.protectEditing}
/>
</div>
{options.protectEditing && (
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
)}
<p className="text-sm text-gray-400">最近更新:{formattedUpdatedAt}</p>
</div>
<div className="flex-1 overflow-y-auto px-12 py-6">
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={initialContent}
pageOptions={options}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
/>
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
</div>
</div>
{showInspector && (
<PageOptionsSidebar
documentId={documentId}
options={options}
stats={stats}
onToggle={toggleOption}
onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)}
/>
)}
</div>
<DocumentHistoryDrawer
open={historyOpen}
onOpenChange={setHistoryOpen}
history={history}
onRestore={handleRestoreSnapshot}
/>
</ImagePickerProvider>
);
}