feat(kernel): complete tree-first graph tasks 074-080
This commit is contained in:
@@ -16,18 +16,18 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
|
||||
interface PrivateTreeProps {
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: SidebarTreeNode) => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -172,7 +172,7 @@ function VirtualRow({
|
||||
}
|
||||
|
||||
interface SortableTreeRowProps {
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
depth: number;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
|
||||
@@ -33,11 +33,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import { buildDocumentTree } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
@@ -127,7 +126,7 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
node: DocumentNode;
|
||||
node: SidebarTreeNode;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -138,8 +137,8 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
// Convex 模式专用组件 - 只调用 Convex hooks
|
||||
function SidebarConvex({ initialData }: SidebarProps) {
|
||||
const convexData = useConvexSidebarData(initialData.activeWorkspaceId);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
|
||||
const sidebarData = useSidebarData(initialData);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} />;
|
||||
}
|
||||
|
||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||
@@ -173,7 +172,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const activeId = segments?.[1] ?? "";
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
|
||||
const [tree, setTree] = useState<DocumentNode[]>(() => buildDocumentTree(sidebarData.documents));
|
||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree ?? []);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
@@ -227,7 +226,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
|
||||
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
|
||||
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
|
||||
const [moveEmbedSource, setMoveEmbedSource] = useState<SidebarTreeNode | null>(null);
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -244,11 +243,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setTree(() => {
|
||||
const nextTree = buildDocumentTree(sidebarData.documents);
|
||||
const nextTree = sidebarData.kernelSidebarTree ?? [];
|
||||
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
|
||||
return nextTree;
|
||||
});
|
||||
}, [sidebarData.documents]);
|
||||
}, [sidebarData.kernelSidebarTree]);
|
||||
|
||||
useEffect(() => {
|
||||
setMediaAssets(sidebarData.mediaAssets ?? []);
|
||||
@@ -436,8 +435,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
const map = new Map<string, DocumentNode>();
|
||||
const walk = (nodes: DocumentNode[]) => {
|
||||
const map = new Map<string, SidebarTreeNode>();
|
||||
const walk = (nodes: SidebarTreeNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
map.set(node.id, node);
|
||||
if (node.children.length > 0) {
|
||||
@@ -450,10 +449,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}, [tree]);
|
||||
|
||||
const publicGroupNodesByGroupId = useMemo(() => {
|
||||
const map = new Map<string, DocumentNode[]>();
|
||||
const map = new Map<string, SidebarTreeNode[]>();
|
||||
for (const g of groupPublicSummary) {
|
||||
const nodes: DocumentNode[] = [];
|
||||
const uniq = new Map<string, DocumentNode>();
|
||||
const nodes: SidebarTreeNode[] = [];
|
||||
const uniq = new Map<string, SidebarTreeNode>();
|
||||
for (const d of g.documents ?? []) {
|
||||
const node = nodeById.get(d.documentId);
|
||||
if (node) {
|
||||
@@ -631,23 +630,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[router, setOpen],
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(async (node: DocumentNode, includeTitle = false) => {
|
||||
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
|
||||
const url = buildDocumentUrl(node.id);
|
||||
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
|
||||
await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制");
|
||||
}, []);
|
||||
|
||||
const handleCopyReference = useCallback(async (node: DocumentNode, mode: "inline" | "embed") => {
|
||||
const handleCopyReference = useCallback(async (node: SidebarTreeNode, mode: "inline" | "embed") => {
|
||||
const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`;
|
||||
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
|
||||
}, []);
|
||||
|
||||
const handleCopyId = useCallback(async (node: DocumentNode) => {
|
||||
const handleCopyId = useCallback(async (node: SidebarTreeNode) => {
|
||||
await copyText(node.id, "页面 ID 已复制");
|
||||
}, []);
|
||||
|
||||
const handleDuplicateDocument = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
async (node: SidebarTreeNode) => {
|
||||
const response = await fetch("/api/documents/duplicate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -662,13 +661,13 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[refreshTree],
|
||||
);
|
||||
|
||||
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
|
||||
const openMoveEmbedPicker = useCallback((node: SidebarTreeNode, nextMode: MoveEmbedMode) => {
|
||||
setMoveEmbedSource(node);
|
||||
setMoveEmbedMode(nextMode);
|
||||
setMoveEmbedOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
|
||||
const handleEmbedPrompt = useCallback(async (node: SidebarTreeNode) => {
|
||||
openMoveEmbedPicker(node, "embed");
|
||||
}, [openMoveEmbedPicker]);
|
||||
|
||||
@@ -1380,19 +1379,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as DocumentNode;
|
||||
const nextNode: DocumentNode = {
|
||||
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: DocumentNode[]): boolean => {
|
||||
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;
|
||||
@@ -1442,7 +1448,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[refreshTree],
|
||||
);
|
||||
|
||||
const moveLocalNode = useCallback((currentTree: DocumentNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
const cloned = cloneNodes(currentTree);
|
||||
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
|
||||
if (!removed) {
|
||||
@@ -1730,7 +1736,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
);
|
||||
|
||||
const handleMovePrompt = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
async (node: SidebarTreeNode) => {
|
||||
openMoveEmbedPicker(node, "move");
|
||||
},
|
||||
[openMoveEmbedPicker],
|
||||
@@ -1753,7 +1759,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
);
|
||||
|
||||
const handleDeleteFromContextMenuNode = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
async (node: SidebarTreeNode) => {
|
||||
if (viewMode === "filesystem") {
|
||||
await handleDeleteFileTreeSelection();
|
||||
return;
|
||||
@@ -2140,7 +2146,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}
|
||||
}, [router, signOut, signingOut]);
|
||||
|
||||
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
|
||||
const openContextMenu = useCallback((event: React.MouseEvent, node: SidebarTreeNode) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenu({
|
||||
@@ -2150,7 +2156,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openShareDialog = useCallback((node: DocumentNode) => {
|
||||
const openShareDialog = useCallback((node: SidebarTreeNode) => {
|
||||
setShareTarget({
|
||||
id: node.id,
|
||||
title: node.title ?? null,
|
||||
@@ -2264,7 +2270,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
</div>
|
||||
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
|
||||
{(() => {
|
||||
const renderList = (nodes: DocumentNode[]) => {
|
||||
const renderList = (nodes: SidebarTreeNode[]) => {
|
||||
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
|
||||
if (flat.length === 0) {
|
||||
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
||||
@@ -2842,7 +2848,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
interface SectionListProps {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
nodes: DocumentNode[];
|
||||
nodes: SidebarTreeNode[];
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
@@ -2885,14 +2891,14 @@ function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListPro
|
||||
interface ContextMenuProps {
|
||||
contextMenu: ContextMenuState;
|
||||
onClose: () => void;
|
||||
onOpenRight: (node: DocumentNode) => void;
|
||||
onShare: (node: DocumentNode) => void;
|
||||
onMove: (node: DocumentNode) => void;
|
||||
onEmbed: (node: DocumentNode) => void;
|
||||
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
|
||||
onCopyReference: (node: DocumentNode, mode: "inline" | "embed") => void;
|
||||
onCopyId: (node: DocumentNode) => void;
|
||||
onDuplicate: (node: DocumentNode) => void;
|
||||
onOpenRight: (node: SidebarTreeNode) => void;
|
||||
onShare: (node: SidebarTreeNode) => void;
|
||||
onMove: (node: SidebarTreeNode) => void;
|
||||
onEmbed: (node: SidebarTreeNode) => void;
|
||||
onCopyLink: (node: SidebarTreeNode, withTitle?: boolean) => void;
|
||||
onCopyReference: (node: SidebarTreeNode, mode: "inline" | "embed") => void;
|
||||
onCopyId: (node: SidebarTreeNode) => void;
|
||||
onDuplicate: (node: SidebarTreeNode) => void;
|
||||
onRename: () => void;
|
||||
onCreateChild: () => void;
|
||||
onConvertChild: () => void;
|
||||
@@ -3064,17 +3070,17 @@ function ContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
const cloneNodes = (nodes: DocumentNode[]): DocumentNode[] =>
|
||||
const cloneNodes = (nodes: SidebarTreeNode[]): SidebarTreeNode[] =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
children: cloneNodes(node.children),
|
||||
}));
|
||||
|
||||
const removeNode = (
|
||||
nodes: DocumentNode[],
|
||||
nodes: SidebarTreeNode[],
|
||||
targetId: string,
|
||||
): { removed: DocumentNode | null; tree: DocumentNode[] } => {
|
||||
let removed: DocumentNode | null = null;
|
||||
): { removed: SidebarTreeNode | null; tree: SidebarTreeNode[] } => {
|
||||
let removed: SidebarTreeNode | null = null;
|
||||
const nextTree = nodes
|
||||
.map((node) => {
|
||||
if (removed) return node;
|
||||
@@ -3089,11 +3095,11 @@ const removeNode = (
|
||||
}
|
||||
return node;
|
||||
})
|
||||
.filter(Boolean) as DocumentNode[];
|
||||
.filter(Boolean) as SidebarTreeNode[];
|
||||
return { removed, tree: nextTree };
|
||||
};
|
||||
|
||||
const insertNode = (nodes: DocumentNode[], parentId: string | null, index: number, newNode: DocumentNode): DocumentNode[] => {
|
||||
const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
|
||||
if (!parentId) {
|
||||
const next = [...nodes];
|
||||
next.splice(Math.min(index, next.length), 0, newNode);
|
||||
@@ -3110,11 +3116,11 @@ const insertNode = (nodes: DocumentNode[], parentId: string | null, index: numbe
|
||||
});
|
||||
};
|
||||
|
||||
const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
|
||||
const filterTree = (nodes: SidebarTreeNode[], keyword: string): SidebarTreeNode[] => {
|
||||
if (!keyword) {
|
||||
return nodes;
|
||||
}
|
||||
const filtered: DocumentNode[] = [];
|
||||
const filtered: SidebarTreeNode[] = [];
|
||||
nodes.forEach((node) => {
|
||||
const childMatches = filterTree(node.children, keyword);
|
||||
const title = (node.title ?? "").toLowerCase();
|
||||
@@ -3125,7 +3131,7 @@ const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
|
||||
return filtered;
|
||||
};
|
||||
|
||||
function collectNodeIds(nodes: DocumentNode[], bag: Set<string> = new Set()): Set<string> {
|
||||
function collectNodeIds(nodes: SidebarTreeNode[], bag: Set<string> = new Set()): Set<string> {
|
||||
nodes.forEach((node) => {
|
||||
bag.add(node.id);
|
||||
collectNodeIds(node.children, bag);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { KernelSidebarProjection, SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
|
||||
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
|
||||
|
||||
@@ -16,6 +17,8 @@ export interface SidebarInitialData {
|
||||
activeWorkspaceId: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernelSidebarProjection?: KernelSidebarProjection | null;
|
||||
kernelSidebarTree?: SidebarTreeNode[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
|
||||
Reference in New Issue
Block a user