feat: 接入 mnote web tree shell 与主页链路整理
- 增加 mnote-web tree/command 支持与前端 MnoteWebTreeShell 集成 - 调整 sidebar、documents、runtime config 与 dev/prod server 配套逻辑 - 补充 homepage/tree shell smoke 脚本并更新 harness 进度文件
This commit is contained in:
@@ -3,11 +3,13 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||
@@ -27,6 +29,10 @@ type PickerItem =
|
||||
| { kind: "root"; id: null; title: string; subtitle?: string }
|
||||
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
|
||||
|
||||
type ViewerIdentity = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
|
||||
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
method: "GET",
|
||||
@@ -42,6 +48,18 @@ async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchViewerIdentity(): Promise<ViewerIdentity> {
|
||||
const response = await fetch("/api/auth/whoami", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || typeof payload?.userId !== "string" || !payload.userId.trim()) {
|
||||
throw new Error(payload?.error ?? "获取当前用户失败");
|
||||
}
|
||||
return { userId: payload.userId.trim() };
|
||||
}
|
||||
|
||||
interface MoveEmbedPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -129,6 +147,10 @@ function MoveEmbedPickerDialogBody({
|
||||
|
||||
const trimmed = query.trim();
|
||||
const isEmptyQuery = trimmed.length === 0;
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const treeShellEnabled = Boolean(
|
||||
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
|
||||
);
|
||||
|
||||
const sidebarQuery = useQuery({
|
||||
queryKey: ["move-embed-picker-sidebar", workspaceId],
|
||||
@@ -143,6 +165,14 @@ function MoveEmbedPickerDialogBody({
|
||||
gcTime: 60_000,
|
||||
});
|
||||
|
||||
const viewerQuery = useQuery({
|
||||
queryKey: ["move-embed-picker-viewer"],
|
||||
queryFn: fetchViewerIdentity,
|
||||
enabled: Boolean(workspaceId) && isEmptyQuery && treeShellEnabled,
|
||||
staleTime: 60_000,
|
||||
gcTime: 120_000,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useDocumentSearch(payload, Boolean(payload) && !isEmptyQuery);
|
||||
|
||||
const items = useMemo<PickerItem[]>(() => {
|
||||
@@ -190,6 +220,39 @@ function MoveEmbedPickerDialogBody({
|
||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
|
||||
|
||||
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||
const allowRootPick = allowRoot && mode === "move";
|
||||
const pickerFallback = (
|
||||
sidebarQuery.isLoading ? (
|
||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||
) : sidebarQuery.error ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(sidebarQuery.error)}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{items.map((item, idx) => (
|
||||
<button
|
||||
key={item.kind === "root" ? "root" : item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||
idx === highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(idx)}
|
||||
onClick={() => void handlePick(item.id)}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium text-gray-900"
|
||||
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const handlePick = useCallback(
|
||||
async (targetId: string | null) => {
|
||||
@@ -232,6 +295,9 @@ function MoveEmbedPickerDialogBody({
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
onKeyDown={(event) => {
|
||||
if (isEmptyQuery) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
||||
@@ -248,10 +314,34 @@ function MoveEmbedPickerDialogBody({
|
||||
>
|
||||
{!workspaceId ? (
|
||||
<div className="p-4 text-sm text-gray-500">缺少 workspaceId,无法加载页面列表。</div>
|
||||
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? (
|
||||
) : isEmptyQuery ? (
|
||||
<div className="h-full p-3">
|
||||
{!treeShellEnabled ? (
|
||||
pickerFallback
|
||||
) : viewerQuery.isLoading ? (
|
||||
<div className="p-4 text-sm text-gray-400">正在准备选择器...</div>
|
||||
) : viewerQuery.error ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(viewerQuery.error)}</div>
|
||||
) : (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={workspaceId}
|
||||
actorId={viewerQuery.data?.userId ?? null}
|
||||
mode="picker"
|
||||
allowRootPick={allowRootPick}
|
||||
excludeIds={excludeIds}
|
||||
onNavigate={() => {}}
|
||||
onPick={(targetId) => {
|
||||
void handlePick(targetId);
|
||||
}}
|
||||
onRefresh={async () => undefined}
|
||||
fallback={pickerFallback}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(error)}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { MediaAsset } from "@/types/media";
|
||||
import { customBlockSchema, type CustomBlockSchema } from "./schema";
|
||||
import { CustomSideMenu } from "./menus/CustomSideMenu";
|
||||
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
|
||||
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
@@ -36,6 +35,7 @@ import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import type { EditorReferenceBridge } from "@/store/editor-bridge";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
documentId: string;
|
||||
@@ -197,6 +197,10 @@ export function BlockNoteEditor({
|
||||
const isFullScreenTableOpen = fullScreenTableId !== null;
|
||||
const revisionRef = useRef<number | null>(initialRevision);
|
||||
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
|
||||
const onStatsChangeRef = useRef(onStatsChange);
|
||||
const onSnapshotRef = useRef(onSnapshot);
|
||||
const onPersistedMetaChangeRef = useRef(onPersistedMetaChange);
|
||||
const stableInitialContentRef = useRef<{ documentId: string; content: Json | undefined } | null>(null);
|
||||
|
||||
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
|
||||
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
|
||||
@@ -226,6 +230,16 @@ export function BlockNoteEditor({
|
||||
[initialContent],
|
||||
);
|
||||
|
||||
if (
|
||||
!stableInitialContentRef.current ||
|
||||
stableInitialContentRef.current.documentId !== documentId
|
||||
) {
|
||||
stableInitialContentRef.current = {
|
||||
documentId,
|
||||
content: normalizedInitialContent,
|
||||
};
|
||||
}
|
||||
|
||||
const collaboration = useMemo(() => {
|
||||
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
|
||||
if (!url) return null;
|
||||
@@ -240,7 +254,7 @@ export function BlockNoteEditor({
|
||||
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
initialContent: normalizedInitialContent as never,
|
||||
initialContent: stableInitialContentRef.current?.content as never,
|
||||
schema: customBlockSchema,
|
||||
placeholders: {
|
||||
default: "输入'/'选择,按 空格 打开AI...",
|
||||
@@ -257,7 +271,7 @@ export function BlockNoteEditor({
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
[documentId, normalizedInitialContent],
|
||||
[documentId],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -268,6 +282,20 @@ export function BlockNoteEditor({
|
||||
[collaboration],
|
||||
);
|
||||
|
||||
const editorRef = useRef(editor);
|
||||
const insertMediaAssetRef = useRef<(asset: MediaAsset) => void>(() => {});
|
||||
const insertMindmapRef = useRef<(args: { documentId: string; mindmapId: string }) => void>(() => {});
|
||||
const insertOnlineTableRef = useRef<(args: { documentId: string; tableId: string }) => void>(() => {});
|
||||
const setFullScreenTableIdRef = useRef(setFullScreenTableId);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current = editor;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
setFullScreenTableIdRef.current = setFullScreenTableId;
|
||||
}, [setFullScreenTableId]);
|
||||
|
||||
useEffect(() => {
|
||||
revisionRef.current = initialRevision;
|
||||
}, [initialRevision]);
|
||||
@@ -276,6 +304,18 @@ export function BlockNoteEditor({
|
||||
conflictDetectionKeyRef.current = initialConflictDetectionKey;
|
||||
}, [initialConflictDetectionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatsChangeRef.current = onStatsChange;
|
||||
}, [onStatsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSnapshotRef.current = onSnapshot;
|
||||
}, [onSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
onPersistedMetaChangeRef.current = onPersistedMetaChange;
|
||||
}, [onPersistedMetaChange]);
|
||||
|
||||
const saveContent = useCallback(
|
||||
async (content: Json) => {
|
||||
setIsSaving(true);
|
||||
@@ -324,7 +364,7 @@ export function BlockNoteEditor({
|
||||
: conflictDetectionKeyRef.current;
|
||||
revisionRef.current = nextRevision ?? null;
|
||||
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
|
||||
onPersistedMetaChange?.({
|
||||
onPersistedMetaChangeRef.current?.({
|
||||
revision: revisionRef.current,
|
||||
conflictDetectionKey: conflictDetectionKeyRef.current,
|
||||
});
|
||||
@@ -337,7 +377,7 @@ export function BlockNoteEditor({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[documentId, onPersistedMetaChange, workspaceId],
|
||||
[documentId, workspaceId],
|
||||
);
|
||||
|
||||
const debouncedSave = useDebouncedCallback(saveContent, 800);
|
||||
@@ -705,8 +745,8 @@ export function BlockNoteEditor({
|
||||
setTocEntries(buildHeadingToc(typedBlocks));
|
||||
syncProgressMeters(editor);
|
||||
const stats = computeDocumentStats(typedBlocks);
|
||||
onStatsChange?.(stats);
|
||||
onSnapshot?.({ blocks: blocks as Json, stats });
|
||||
onStatsChangeRef.current?.(stats);
|
||||
onSnapshotRef.current?.({ blocks: blocks as Json, stats });
|
||||
|
||||
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
|
||||
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
|
||||
@@ -745,7 +785,7 @@ export function BlockNoteEditor({
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]);
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, restoreOnlineTableIfNeeded]);
|
||||
|
||||
const jumpToHeading = useCallback((headingId: string) => {
|
||||
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
||||
@@ -1003,6 +1043,18 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
insertMediaAssetRef.current = insertMediaAssetBlock;
|
||||
}, [insertMediaAssetBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
insertMindmapRef.current = insertMindmapBlock;
|
||||
}, [insertMindmapBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
insertOnlineTableRef.current = insertOnlineTableBlock;
|
||||
}, [insertOnlineTableBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return undefined;
|
||||
|
||||
@@ -1090,46 +1142,48 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
[documentId, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
registerEditorBridge(null);
|
||||
return;
|
||||
}
|
||||
const bridge = {
|
||||
const stableEditorBridge = useMemo<EditorReferenceBridge>(
|
||||
() => ({
|
||||
undo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.undo();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.focus();
|
||||
currentEditor.undo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
redo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.redo();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.focus();
|
||||
currentEditor.redo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
openTableFullScreen: (tableId: string) => {
|
||||
setFullScreenTableId(tableId);
|
||||
setFullScreenTableIdRef.current(tableId);
|
||||
},
|
||||
insertMediaAsset: (asset: MediaAsset) => {
|
||||
insertMediaAssetBlock(asset);
|
||||
insertMediaAssetRef.current(asset);
|
||||
},
|
||||
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
|
||||
insertMindmapBlock(args);
|
||||
insertMindmapRef.current(args);
|
||||
},
|
||||
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
|
||||
insertOnlineTableBlock(args);
|
||||
insertOnlineTableRef.current(args);
|
||||
},
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return { blockId: null };
|
||||
currentEditor.focus();
|
||||
const cursor = currentEditor.getTextCursorPosition();
|
||||
const blockId = cursor?.block?.id ?? null;
|
||||
const text = aliasText || target.title || "无标题";
|
||||
editor.insertInlineContent([
|
||||
currentEditor.insertInlineContent([
|
||||
{
|
||||
type: "link",
|
||||
href: buildDocumentPath(target.id),
|
||||
@@ -1140,12 +1194,14 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
return { blockId };
|
||||
},
|
||||
insertEmbedReference: (target: ReferenceTarget) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return { blockId: null };
|
||||
currentEditor.focus();
|
||||
const cursor = currentEditor.getTextCursorPosition();
|
||||
const referenceBlock =
|
||||
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
|
||||
cursor?.block ?? currentEditor.topLevelBlocks[currentEditor.topLevelBlocks.length - 1];
|
||||
const blockId = generateBlockId();
|
||||
editor.insertBlocks(
|
||||
currentEditor.insertBlocks(
|
||||
[
|
||||
{
|
||||
id: blockId,
|
||||
@@ -1162,7 +1218,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
return { blockId };
|
||||
},
|
||||
replaceWithSnapshot: (payload: Json) => {
|
||||
editor.focus();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.focus();
|
||||
const nextBlocks = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray((payload as { blocks?: Json }).blocks)
|
||||
@@ -1171,20 +1229,29 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
if (!Array.isArray(nextBlocks)) {
|
||||
return;
|
||||
}
|
||||
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
|
||||
currentEditor.replaceBlocks(currentEditor.topLevelBlocks, nextBlocks as never);
|
||||
},
|
||||
getCursorBlockId: () => {
|
||||
try {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return null;
|
||||
const cursor = currentEditor.getTextCursorPosition();
|
||||
return cursor?.block?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
registerEditorBridge(bridge);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerEditorBridge(editor ? stableEditorBridge : null);
|
||||
}, [editor, registerEditorBridge, stableEditorBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => registerEditorBridge(null);
|
||||
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
|
||||
}, [registerEditorBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
@@ -1336,9 +1403,6 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
</div>
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
|
||||
</div>
|
||||
|
||||
<MoveEmbedPickerHost />
|
||||
|
||||
{/* 全屏表格编辑器 Modal */}
|
||||
{fullScreenTableId && (
|
||||
<FullScreenTableEditor
|
||||
|
||||
@@ -3,14 +3,10 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
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";
|
||||
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";
|
||||
@@ -25,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { DocumentToc } from "@/components/editor/document-toc";
|
||||
import { DocumentReadView } from "@/components/editor/document-read-view";
|
||||
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { moveDocumentCommand, renameDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -36,6 +33,45 @@ const BlockNoteEditor = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
// 说明:这些组件都不是“进入文档页首屏”所必需。
|
||||
// 先拆成独立 chunk,避免 /documents/[id] 首次编译时把评论、历史、弹层、检查器等整串依赖一并拉进来。
|
||||
const PageOptionsSidebar = dynamic(
|
||||
() => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const PageBacklinksPanel = dynamic(
|
||||
() => import("@/components/editor/page-backlinks-panel").then((mod) => mod.PageBacklinksPanel),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
const DocumentHistoryDrawer = dynamic(
|
||||
() => import("@/components/editor/document-history-drawer").then((mod) => mod.DocumentHistoryDrawer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
const MoveEmbedPickerHost = dynamic(
|
||||
() => import("@/components/documents/move-embed-picker-host").then((mod) => mod.MoveEmbedPickerHost),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
const DocumentCommentsDrawer = dynamic(
|
||||
() => import("@/components/editor/document-comments-drawer").then((mod) => mod.DocumentCommentsDrawer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export interface DocumentContentProps {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
@@ -358,11 +394,11 @@ export function DocumentContent({
|
||||
async (nextTitle: string) => {
|
||||
if (readOnly) return;
|
||||
const payload = nextTitle.trim() || "无标题";
|
||||
await fetch("/api/documents/title", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, workspaceId, title: payload }),
|
||||
});
|
||||
try {
|
||||
await renameDocumentCommand({ documentId, workspaceId, title: payload });
|
||||
} catch (error) {
|
||||
console.error("更新页面标题失败", error);
|
||||
}
|
||||
},
|
||||
[documentId, readOnly, workspaceId],
|
||||
);
|
||||
@@ -590,14 +626,15 @@ export function DocumentContent({
|
||||
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 ?? "移动失败");
|
||||
try {
|
||||
await moveDocumentCommand({
|
||||
documentId,
|
||||
parentId: targetId ?? null,
|
||||
position: 999999,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "移动失败");
|
||||
return;
|
||||
}
|
||||
window.alert("移动成功");
|
||||
@@ -640,7 +677,18 @@ export function DocumentContent({
|
||||
|
||||
const formattedUpdatedAt = useMemo(() => {
|
||||
if (!updatedAt) return "";
|
||||
return new Date(updatedAt).toLocaleString();
|
||||
const date = new Date(updatedAt);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}, [updatedAt]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
@@ -924,6 +972,7 @@ export function DocumentContent({
|
||||
history={history}
|
||||
onRestore={handleRestoreSnapshot}
|
||||
/>
|
||||
<MoveEmbedPickerHost />
|
||||
<DocumentCommentsDrawer />
|
||||
<DocumentAiAgentPanel
|
||||
documentId={documentId}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { deleteOnlineTable } from "@/lib/online-table";
|
||||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { createChildDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
|
||||
type InlineNode = { text?: unknown };
|
||||
type TableMenuBlock = Parameters<
|
||||
@@ -196,32 +197,26 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
}, [block, currentDocumentId, editor, removePageReference]);
|
||||
|
||||
const turnToPage = useCallback(async () => {
|
||||
const response = await fetch("/api/documents/create-child", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
const payload = await createChildDocumentCommand({
|
||||
parentId: currentDocumentId,
|
||||
title: extractText(block),
|
||||
blocks: [block],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
editor.replaceBlocks(
|
||||
[block.id],
|
||||
[
|
||||
{
|
||||
type: "pageReference",
|
||||
props: { pageId: payload.pageId, title: payload.title, asChildPage: true },
|
||||
} as PartialBlock<CustomBlockSchema>,
|
||||
],
|
||||
);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("块转页面失败", error);
|
||||
}
|
||||
|
||||
const { pageId, title } = await response.json();
|
||||
|
||||
editor.replaceBlocks(
|
||||
[block.id],
|
||||
[
|
||||
{
|
||||
type: "pageReference",
|
||||
props: { pageId, title, asChildPage: true },
|
||||
} as PartialBlock<CustomBlockSchema>,
|
||||
],
|
||||
);
|
||||
router.refresh();
|
||||
}, [block, currentDocumentId, editor, router]);
|
||||
|
||||
const handleMoveEmbedPick = useCallback(
|
||||
|
||||
@@ -39,15 +39,26 @@ const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
|
||||
);
|
||||
|
||||
export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
|
||||
workspaceId,
|
||||
documentId,
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const records = useMemo(() => data ?? [], [data]);
|
||||
// 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。
|
||||
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed));
|
||||
useEffect(() => setIsCollapsed(Boolean(defaultCollapsed)), [defaultCollapsed]);
|
||||
useEffect(() => {
|
||||
setIsReady(false);
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsReady(true);
|
||||
}, 600);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [documentId, workspaceId]);
|
||||
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
|
||||
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
|
||||
if (!error && records.length === 0 && (isLoading || isFetching)) {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
|
||||
const TREE_SHELL_PATH = "/tree";
|
||||
|
||||
export type MnoteTreeShellMode = "page" | "picker" | "filetree";
|
||||
|
||||
type TreeShellMessage =
|
||||
| {
|
||||
channel?: string;
|
||||
type?: string;
|
||||
documentId?: string;
|
||||
assetId?: string;
|
||||
rowId?: string;
|
||||
rowKind?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
target?: { documentId?: string };
|
||||
payload?: {
|
||||
documentId?: string;
|
||||
assetId?: string;
|
||||
rowId?: string;
|
||||
rowKind?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type MnoteWebTreeShellProps = {
|
||||
workspaceId: string | null;
|
||||
activeDocumentId?: string;
|
||||
actorId?: string | null;
|
||||
mode?: MnoteTreeShellMode;
|
||||
allowRootPick?: boolean;
|
||||
excludeIds?: string[];
|
||||
reloadToken?: number;
|
||||
onNavigate: (documentId: string) => void;
|
||||
onPick?: (documentId: string | null) => void;
|
||||
onOpenAsset?: (assetId: string) => void;
|
||||
onOpenContextMenu?: (args: { documentId: string; x: number; y: number }) => void;
|
||||
onOpenFileTreeContextMenu?: (args: {
|
||||
documentId?: string;
|
||||
assetId?: string;
|
||||
rowId?: string;
|
||||
rowKind?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
fallback: React.ReactNode;
|
||||
};
|
||||
|
||||
function buildShellUrl(
|
||||
baseUrl: string,
|
||||
workspaceId: string | null,
|
||||
activeDocumentId: string | undefined,
|
||||
actorId: string | null | undefined,
|
||||
mode: MnoteTreeShellMode,
|
||||
allowRootPick: boolean,
|
||||
excludeIds: string[],
|
||||
refreshKey: number,
|
||||
reloadToken: number,
|
||||
) {
|
||||
const url = new URL(TREE_SHELL_PATH, `${baseUrl}/`);
|
||||
if (workspaceId) {
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
}
|
||||
if (activeDocumentId) {
|
||||
url.searchParams.set("activeDocumentId", activeDocumentId);
|
||||
}
|
||||
if (actorId && actorId.trim()) {
|
||||
url.searchParams.set("actorId", actorId.trim());
|
||||
}
|
||||
url.searchParams.set("mode", mode);
|
||||
if (mode === "picker") {
|
||||
url.searchParams.set("allowRootPick", allowRootPick ? "1" : "0");
|
||||
if (excludeIds.length > 0) {
|
||||
url.searchParams.set("excludeIds", excludeIds.join(","));
|
||||
}
|
||||
}
|
||||
url.searchParams.set("host", "wolai-frontend");
|
||||
url.searchParams.set("channel", TREE_SHELL_CHANNEL);
|
||||
url.searchParams.set("v", `${refreshKey}-${reloadToken}`);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function extractDocumentId(message: Exclude<TreeShellMessage, null | undefined>) {
|
||||
const direct = typeof message.documentId === "string" ? message.documentId.trim() : "";
|
||||
if (direct) return direct;
|
||||
|
||||
const fromTarget =
|
||||
message.target && typeof message.target.documentId === "string"
|
||||
? message.target.documentId.trim()
|
||||
: "";
|
||||
if (fromTarget) return fromTarget;
|
||||
|
||||
const fromPayload =
|
||||
message.payload && typeof message.payload.documentId === "string"
|
||||
? message.payload.documentId.trim()
|
||||
: "";
|
||||
return fromPayload;
|
||||
}
|
||||
|
||||
function extractAssetId(message: Exclude<TreeShellMessage, null | undefined>) {
|
||||
const direct = typeof message.assetId === "string" ? message.assetId.trim() : "";
|
||||
if (direct) return direct;
|
||||
|
||||
const fromPayload =
|
||||
message.payload && typeof message.payload.assetId === "string"
|
||||
? message.payload.assetId.trim()
|
||||
: "";
|
||||
return fromPayload;
|
||||
}
|
||||
|
||||
function extractCoordinate(
|
||||
message: Exclude<TreeShellMessage, null | undefined>,
|
||||
axis: "x" | "y",
|
||||
) {
|
||||
const direct = typeof message[axis] === "number" ? message[axis] : null;
|
||||
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
|
||||
|
||||
const fromPayload =
|
||||
message.payload && typeof message.payload[axis] === "number"
|
||||
? message.payload[axis]
|
||||
: null;
|
||||
if (typeof fromPayload === "number" && Number.isFinite(fromPayload)) return fromPayload;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTextField(
|
||||
message: Exclude<TreeShellMessage, null | undefined>,
|
||||
field: "rowId" | "rowKind",
|
||||
) {
|
||||
const direct = typeof message[field] === "string" ? message[field].trim() : "";
|
||||
if (direct) return direct;
|
||||
|
||||
const fromPayload =
|
||||
message.payload && typeof message.payload[field] === "string"
|
||||
? message.payload[field].trim()
|
||||
: "";
|
||||
return fromPayload;
|
||||
}
|
||||
|
||||
export function MnoteWebTreeShell({
|
||||
workspaceId,
|
||||
activeDocumentId,
|
||||
actorId,
|
||||
mode = "page",
|
||||
allowRootPick = false,
|
||||
excludeIds = [],
|
||||
reloadToken = 0,
|
||||
onNavigate,
|
||||
onPick,
|
||||
onOpenAsset,
|
||||
onOpenContextMenu,
|
||||
onOpenFileTreeContextMenu,
|
||||
onRefresh,
|
||||
fallback,
|
||||
}: MnoteWebTreeShellProps) {
|
||||
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
|
||||
const treeShellEnabled = runtime.mnoteWebTreeShellEnabled === true;
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const readyTimerRef = useRef<number | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
|
||||
|
||||
const shellUrl = useMemo(() => {
|
||||
if (!baseUrl || !treeShellEnabled) return null;
|
||||
return buildShellUrl(
|
||||
baseUrl,
|
||||
workspaceId,
|
||||
activeDocumentId,
|
||||
actorId,
|
||||
mode,
|
||||
allowRootPick,
|
||||
excludeIds,
|
||||
refreshKey,
|
||||
reloadToken,
|
||||
);
|
||||
}, [
|
||||
activeDocumentId,
|
||||
actorId,
|
||||
allowRootPick,
|
||||
baseUrl,
|
||||
excludeIds,
|
||||
mode,
|
||||
reloadToken,
|
||||
refreshKey,
|
||||
treeShellEnabled,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellUrl) return;
|
||||
if (readyTimerRef.current) {
|
||||
window.clearTimeout(readyTimerRef.current);
|
||||
}
|
||||
// 说明:当前 shell 仍处于渐进接入期,若路由不存在或页面未按协议 ready,
|
||||
// 前端应自动回退到旧 React 树,而不是让用户看到空白 iframe。
|
||||
readyTimerRef.current = window.setTimeout(() => {
|
||||
setFailedShellUrl(shellUrl);
|
||||
}, 2500);
|
||||
return () => {
|
||||
if (readyTimerRef.current) {
|
||||
window.clearTimeout(readyTimerRef.current);
|
||||
readyTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shellUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl || !treeShellEnabled) return;
|
||||
|
||||
const expectedOrigin = (() => {
|
||||
try {
|
||||
return new URL(baseUrl).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
|
||||
const onMessage = (event: MessageEvent<TreeShellMessage>) => {
|
||||
if (!expectedOrigin || event.origin !== expectedOrigin) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (message.channel !== TREE_SHELL_CHANNEL) return;
|
||||
|
||||
const type = typeof message.type === "string" ? message.type.trim() : "";
|
||||
if (!type) return;
|
||||
|
||||
if (type === "tree.ready" || type === "ready") {
|
||||
if (readyTimerRef.current) {
|
||||
window.clearTimeout(readyTimerRef.current);
|
||||
readyTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tree.navigate" || type === "navigate") {
|
||||
const documentId = extractDocumentId(message);
|
||||
if (documentId) {
|
||||
onNavigate(documentId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tree.pick" || type === "picker.pick") {
|
||||
onPick?.(extractDocumentId(message) || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tree.pick.root" || type === "picker.pick.root") {
|
||||
onPick?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tree.asset.open" || type === "filetree.asset.open") {
|
||||
const assetId = extractAssetId(message);
|
||||
if (assetId) {
|
||||
onOpenAsset?.(assetId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tree.context-menu" || type === "tree.page.context-menu") {
|
||||
const documentId = extractDocumentId(message);
|
||||
const x = extractCoordinate(message, "x");
|
||||
const y = extractCoordinate(message, "y");
|
||||
if (!documentId || x === null || y === null) {
|
||||
return;
|
||||
}
|
||||
const iframeRect = iframeRef.current?.getBoundingClientRect();
|
||||
if (!iframeRect) {
|
||||
return;
|
||||
}
|
||||
onOpenContextMenu?.({
|
||||
documentId,
|
||||
x: iframeRect.left + x,
|
||||
y: iframeRect.top + y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tree.filetree.context-menu") {
|
||||
const documentId = extractDocumentId(message) || undefined;
|
||||
const assetId = extractAssetId(message) || undefined;
|
||||
const rowId = extractTextField(message, "rowId") || undefined;
|
||||
const rowKind = extractTextField(message, "rowKind") || undefined;
|
||||
const x = extractCoordinate(message, "x");
|
||||
const y = extractCoordinate(message, "y");
|
||||
if (x === null || y === null) {
|
||||
return;
|
||||
}
|
||||
const iframeRect = iframeRef.current?.getBoundingClientRect();
|
||||
if (!iframeRect) {
|
||||
return;
|
||||
}
|
||||
onOpenFileTreeContextMenu?.({
|
||||
documentId,
|
||||
assetId,
|
||||
rowId,
|
||||
rowKind,
|
||||
x: iframeRect.left + x,
|
||||
y: iframeRect.top + y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
type === "tree.node.created" ||
|
||||
type === "tree.node.renamed" ||
|
||||
type === "tree.subtree.moved" ||
|
||||
type === "tree.refresh" ||
|
||||
type === "refresh"
|
||||
) {
|
||||
void onRefresh().finally(() => {
|
||||
setRefreshKey((value) => value + 1);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
|
||||
|
||||
if (!shellUrl || failedShellUrl === shellUrl) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full min-w-0 overflow-hidden bg-white">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="mnote-web tree shell"
|
||||
src={shellUrl}
|
||||
className="h-full w-full border-0 bg-white"
|
||||
loading="lazy"
|
||||
onError={() => {
|
||||
setFailedShellUrl(shellUrl);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { useConvex } from "convex/react";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ArrowUpRight,
|
||||
@@ -36,7 +37,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
|
||||
@@ -68,6 +70,11 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
|
||||
import {
|
||||
createDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
renameDocumentCommand,
|
||||
} from "@/lib/documents/tree-command-client";
|
||||
|
||||
const TOP_BUTTONS = [
|
||||
{ id: "search", icon: SearchIcon, label: "搜索" },
|
||||
@@ -148,20 +155,22 @@ function SidebarConvex({ initialData }: SidebarProps) {
|
||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||
interface SidebarContentProps {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQuery: {
|
||||
data: SidebarInitialData | null | undefined;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
sidebarQuery: SidebarDataResult;
|
||||
}
|
||||
|
||||
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const convex = useConvex();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
|
||||
useSidebarStore();
|
||||
const viewMode = useSidebarStore((state) => state.viewMode);
|
||||
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const treeShellAvailable = Boolean(
|
||||
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
|
||||
);
|
||||
|
||||
// 处理数据
|
||||
const sidebarData = useMemo(() => {
|
||||
@@ -234,6 +243,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [treeShellReloadToken, setTreeShellReloadToken] = useState(0);
|
||||
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
|
||||
selectedRowIds: new Set(),
|
||||
anchorRowId: null,
|
||||
@@ -304,6 +314,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
await sidebarQuery.refetch();
|
||||
setTreeShellReloadToken((prev) => prev + 1);
|
||||
}, [sidebarQuery]);
|
||||
|
||||
const refreshShareSummary = useCallback(async () => {
|
||||
@@ -585,6 +596,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return map;
|
||||
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
|
||||
|
||||
const assetById = useMemo(() => {
|
||||
const map = new Map<string, MediaAsset>();
|
||||
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
|
||||
map.set(asset.id, asset);
|
||||
});
|
||||
return map;
|
||||
}, [mediaAssets, mindmapAssets, tableAssets]);
|
||||
|
||||
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const fileTreeRows = useMemo(
|
||||
@@ -652,6 +671,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[router, setOpen],
|
||||
);
|
||||
|
||||
const handleNavigateFromTreeShell = useCallback(
|
||||
(documentId: string) => {
|
||||
if (!documentId) return;
|
||||
handleOpenDocument(documentId, "main");
|
||||
},
|
||||
[handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
|
||||
const url = buildDocumentUrl(node.id);
|
||||
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
|
||||
@@ -1264,9 +1291,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setAssetMenu(null);
|
||||
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
|
||||
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
|
||||
await sidebarQuery.refetch();
|
||||
await refreshTree();
|
||||
},
|
||||
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets],
|
||||
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
|
||||
);
|
||||
|
||||
const handleDeleteFileTreeSelection = useCallback(async () => {
|
||||
@@ -1389,60 +1416,50 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}
|
||||
creatingDocumentUnderParentRef.current.add(creatingKey);
|
||||
try {
|
||||
const response = await fetch("/api/documents/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parentId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "新建页面失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as SidebarTreeNode;
|
||||
const nextNode: SidebarTreeNode = {
|
||||
...payload,
|
||||
access_scope: payload.access_scope ?? "private",
|
||||
is_template: payload.is_template ?? false,
|
||||
updated_at: payload.updated_at ?? payload.created_at,
|
||||
title: payload.title ?? "无标题",
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: payload.sort_order ?? null,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
};
|
||||
|
||||
setTree((prev) => {
|
||||
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
|
||||
const exists = (nodes: SidebarTreeNode[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nextNode.id) return true;
|
||||
if (node.children.length > 0 && exists(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
const payload = (await createDocumentCommand(parentId)) as SidebarTreeNode;
|
||||
const nextNode: SidebarTreeNode = {
|
||||
...payload,
|
||||
access_scope: payload.access_scope ?? "private",
|
||||
is_template: payload.is_template ?? false,
|
||||
updated_at: payload.updated_at ?? payload.created_at,
|
||||
title: payload.title ?? "无标题",
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: payload.sort_order ?? null,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
};
|
||||
if (exists(prev)) return prev;
|
||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||
});
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (parentId) {
|
||||
next.add(parentId);
|
||||
}
|
||||
if (!parentId) {
|
||||
next.add(nextNode.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
await refreshTree();
|
||||
router.push(`/documents/${nextNode.id}`);
|
||||
setTree((prev) => {
|
||||
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
|
||||
const exists = (nodes: SidebarTreeNode[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nextNode.id) return true;
|
||||
if (node.children.length > 0 && exists(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (exists(prev)) return prev;
|
||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||
});
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (parentId) {
|
||||
next.add(parentId);
|
||||
}
|
||||
if (!parentId) {
|
||||
next.add(nextNode.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
await refreshTree();
|
||||
router.push(`/documents/${nextNode.id}`);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
|
||||
} finally {
|
||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||
}
|
||||
@@ -1456,18 +1473,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
await fetch("/api/documents/title", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
await renameDocumentCommand({
|
||||
documentId,
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
title: title.trim(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
},
|
||||
[refreshTree],
|
||||
[refreshTree, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
@@ -1486,15 +1504,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (parentId) {
|
||||
setExpanded((prev) => new Set(prev).add(parentId));
|
||||
}
|
||||
await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
await moveDocumentCommand({
|
||||
documentId,
|
||||
parentId,
|
||||
position: index,
|
||||
}),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "移动失败,请稍后再试");
|
||||
await refreshTree();
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
},
|
||||
[moveLocalNode, refreshTree, setExpanded],
|
||||
@@ -1710,14 +1730,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setExpanded((prev) => new Set(prev).add(targetDocId));
|
||||
|
||||
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
||||
await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId: topLevelDocIds[i],
|
||||
parentId: targetDocId,
|
||||
position: baseIndex + i,
|
||||
}),
|
||||
await moveDocumentCommand({
|
||||
documentId: topLevelDocIds[i],
|
||||
parentId: targetDocId,
|
||||
position: baseIndex + i,
|
||||
});
|
||||
}
|
||||
await refreshTree();
|
||||
@@ -2178,6 +2194,68 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openContextMenuFromTreeShell = useCallback(
|
||||
({ documentId, x, y }: { documentId: string; x: number; y: number }) => {
|
||||
const node = nodeById.get(documentId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
setContextMenu({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
},
|
||||
[nodeById],
|
||||
);
|
||||
|
||||
const openFileTreeContextMenuFromTreeShell = useCallback(
|
||||
({
|
||||
documentId,
|
||||
assetId,
|
||||
rowId,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
documentId?: string;
|
||||
assetId?: string;
|
||||
rowId?: string;
|
||||
rowKind?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => {
|
||||
if (assetId) {
|
||||
const asset = assetById.get(assetId);
|
||||
if (asset) {
|
||||
if (rowId) {
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
|
||||
);
|
||||
}
|
||||
setAssetMenu({ asset, x, y });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
const node = nodeById.get(documentId);
|
||||
if (node) {
|
||||
if (rowId) {
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
|
||||
);
|
||||
}
|
||||
setContextMenu({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[assetById, nodeById],
|
||||
);
|
||||
|
||||
const openShareDialog = useCallback((node: SidebarTreeNode) => {
|
||||
setShareTarget({
|
||||
id: node.id,
|
||||
@@ -2519,17 +2597,42 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
</button>
|
||||
{!collapsedSections.private ? (
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
{treeShellAvailable ? (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
activeDocumentId={activeId}
|
||||
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
|
||||
reloadToken={treeShellReloadToken}
|
||||
onNavigate={handleNavigateFromTreeShell}
|
||||
onOpenContextMenu={openContextMenuFromTreeShell}
|
||||
onRefresh={refreshTree}
|
||||
fallback={
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
|
||||
@@ -2542,23 +2645,64 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div
|
||||
ref={fileTreeContainerRef}
|
||||
data-testid="file-tree-container"
|
||||
className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white"
|
||||
className="h-full w-full min-w-0 overflow-x-hidden"
|
||||
>
|
||||
<FileTree
|
||||
rows={fileTreeRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||
onRowClick={handleFileTreeRowClick}
|
||||
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onDropFiles={handleFileTreeDropFiles}
|
||||
onInternalDrop={handleFileTreeInternalDrop}
|
||||
/>
|
||||
{treeShellAvailable ? (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
activeDocumentId={activeId}
|
||||
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
|
||||
mode="filetree"
|
||||
reloadToken={treeShellReloadToken}
|
||||
onNavigate={handleNavigateFromTreeShell}
|
||||
onOpenAsset={(assetId) => {
|
||||
const asset = assetById.get(assetId);
|
||||
if (asset) {
|
||||
handleOpenAsset(asset);
|
||||
}
|
||||
}}
|
||||
onOpenContextMenu={openContextMenuFromTreeShell}
|
||||
onOpenFileTreeContextMenu={openFileTreeContextMenuFromTreeShell}
|
||||
onRefresh={refreshTree}
|
||||
fallback={
|
||||
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
|
||||
<FileTree
|
||||
rows={fileTreeRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||
onRowClick={handleFileTreeRowClick}
|
||||
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onDropFiles={handleFileTreeDropFiles}
|
||||
onInternalDrop={handleFileTreeInternalDrop}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
|
||||
<FileTree
|
||||
rows={fileTreeRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||
onRowClick={handleFileTreeRowClick}
|
||||
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onDropFiles={handleFileTreeDropFiles}
|
||||
onInternalDrop={handleFileTreeInternalDrop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user