2025-11-23 10:55:04 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import dynamic from "next/dynamic";
|
2026-01-10 10:35:21 +08:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
2025-11-23 10:55:04 +08:00
|
|
|
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";
|
2026-01-10 10:35:21 +08:00
|
|
|
import { useRouter } from "next/navigation";
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
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;
|
2026-01-10 10:35:21 +08:00
|
|
|
openTableId?: string | null;
|
2025-11-23 10:55:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-01-10 10:35:21 +08:00
|
|
|
openTableId,
|
2025-11-23 10:55:04 +08:00
|
|
|
}: 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);
|
2026-01-10 10:35:21 +08:00
|
|
|
const router = useRouter();
|
2025-11-23 10:55:04 +08:00
|
|
|
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
|
|
|
|
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
2026-01-10 10:35:21 +08:00
|
|
|
const [content, setContent] = useState<unknown>(initialContent);
|
|
|
|
|
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
|
|
|
|
const [contentError, setContentError] = useState<string | null>(null);
|
|
|
|
|
const [contentReloadKey, setContentReloadKey] = useState(0);
|
|
|
|
|
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
|
|
|
|
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const pendingOpenTableRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const tableId = (openTableId ?? "").trim();
|
|
|
|
|
if (!tableId) return;
|
|
|
|
|
if (!editorBridge?.openTableFullScreen) return;
|
|
|
|
|
if (pendingOpenTableRef.current === tableId) return;
|
|
|
|
|
pendingOpenTableRef.current = tableId;
|
|
|
|
|
|
|
|
|
|
editorBridge.openTableFullScreen(tableId);
|
|
|
|
|
|
|
|
|
|
// 清理 URL 参数,避免刷新/回退时重复触发
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(window.location.href);
|
|
|
|
|
url.searchParams.delete("openTableId");
|
|
|
|
|
window.history.replaceState({}, "", url.toString());
|
|
|
|
|
} catch {
|
|
|
|
|
// fallback:不影响主流程
|
|
|
|
|
router.replace(`/documents/${documentId}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [documentId, editorBridge, openTableId, router]);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
/* 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 */
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
let canceled = false;
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
setContentError(null);
|
|
|
|
|
setContentLoading(initialContent == null);
|
|
|
|
|
setContent(initialContent);
|
|
|
|
|
setShowContentLoadingIndicator(false);
|
|
|
|
|
|
|
|
|
|
if (initialContent != null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contentLoadingTimerRef.current) {
|
|
|
|
|
clearTimeout(contentLoadingTimerRef.current);
|
|
|
|
|
contentLoadingTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
// 避免“秒闪”的加载提示:只有当加载超过短阈值时才显示提示
|
|
|
|
|
contentLoadingTimerRef.current = setTimeout(() => {
|
|
|
|
|
if (!canceled) {
|
|
|
|
|
setShowContentLoadingIndicator(true);
|
|
|
|
|
}
|
|
|
|
|
}, 200);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/documents/content?documentId=${encodeURIComponent(documentId)}`, {
|
|
|
|
|
method: "GET",
|
|
|
|
|
credentials: "include",
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
throw new Error(payload?.error ?? "加载页面内容失败");
|
|
|
|
|
}
|
|
|
|
|
const payload = (await response.json()) as { content?: unknown };
|
|
|
|
|
if (canceled) return;
|
|
|
|
|
setContent(payload.content ?? null);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (canceled) return;
|
|
|
|
|
if ((error as { name?: string })?.name === "AbortError") return;
|
|
|
|
|
setContentError(error instanceof Error ? error.message : "加载页面内容失败");
|
|
|
|
|
} finally {
|
|
|
|
|
if (!canceled) {
|
|
|
|
|
setContentLoading(false);
|
|
|
|
|
setShowContentLoadingIndicator(false);
|
|
|
|
|
}
|
|
|
|
|
if (contentLoadingTimerRef.current) {
|
|
|
|
|
clearTimeout(contentLoadingTimerRef.current);
|
|
|
|
|
contentLoadingTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void load();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
canceled = true;
|
|
|
|
|
controller.abort();
|
|
|
|
|
if (contentLoadingTimerRef.current) {
|
|
|
|
|
clearTimeout(contentLoadingTimerRef.current);
|
|
|
|
|
contentLoadingTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [documentId, initialContent, contentReloadKey]);
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
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">
|
2026-01-10 10:35:21 +08:00
|
|
|
{contentLoading ? (
|
|
|
|
|
showContentLoadingIndicator ? (
|
|
|
|
|
<div className="flex h-64 items-center justify-center text-sm text-gray-400">
|
|
|
|
|
页面内容加载中...
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="h-64" />
|
|
|
|
|
)
|
|
|
|
|
) : contentError ? (
|
|
|
|
|
<div className="flex h-64 flex-col items-center justify-center gap-2 text-sm text-red-600">
|
|
|
|
|
<div>{contentError}</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded-md border border-red-200 bg-red-50 px-3 py-1 text-sm text-red-700 hover:bg-red-100"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setContentError(null);
|
|
|
|
|
setContentLoading(true);
|
|
|
|
|
setContentReloadKey((prev) => prev + 1);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
重试
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<BlockNoteEditor
|
|
|
|
|
documentId={documentId}
|
|
|
|
|
workspaceId={workspaceId}
|
|
|
|
|
initialContent={content}
|
|
|
|
|
pageOptions={options}
|
|
|
|
|
onStatsChange={handleStatsChange}
|
|
|
|
|
onSnapshot={handleSnapshot}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-11-23 10:55:04 +08:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|