chore: save snapshot before tag 0.3
This commit is contained in:
@@ -28,13 +28,14 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
let sidebarInitialData: SidebarInitialData | null = null;
|
||||
|
||||
if (activeWorkspaceId) {
|
||||
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
|
||||
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
|
||||
documents = dataset.documents;
|
||||
sidebarInitialData = {
|
||||
activeWorkspaceId,
|
||||
workspaces,
|
||||
documents: dataset.documents,
|
||||
trashedDocuments: dataset.trashedDocuments,
|
||||
mediaAssets: dataset.mediaAssets,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { MindLayoutEngine } from '@/lib/mindmap/layout/MindLayoutEngine';
|
||||
import { MindmapTree, LayoutName, LayoutResult } from '@/lib/mindmap/types';
|
||||
import { useMindmapStore } from '@/store/useMindmapStore';
|
||||
import styles from './mindmap-canvas.module.css';
|
||||
|
||||
export interface MindmapCanvasProps {
|
||||
trees: MindmapTree | MindmapTree[];
|
||||
layout?: LayoutName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const engine = new MindLayoutEngine();
|
||||
|
||||
export function MindmapCanvas({ trees, layout = 'logical', className }: MindmapCanvasProps) {
|
||||
const [state, setState] = useMindmapStore((s) => s);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const treeList = Array.isArray(trees) ? trees : [trees];
|
||||
|
||||
const results = useMemo(() => {
|
||||
return treeList.map((tree) => engine.compute(tree, layout));
|
||||
}, [treeList, layout]);
|
||||
|
||||
const combined: LayoutResult = results.reduce(
|
||||
(acc, result, index) => {
|
||||
const offsetX = index * (result.bounds.width + 120);
|
||||
acc.nodes.push(
|
||||
...result.nodes.map((node) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: node.position.x + offsetX,
|
||||
y: node.position.y,
|
||||
},
|
||||
})),
|
||||
);
|
||||
acc.edges.push(...result.edges);
|
||||
acc.bounds.width = Math.max(acc.bounds.width, offsetX + result.bounds.width);
|
||||
acc.bounds.height = Math.max(acc.bounds.height, result.bounds.height);
|
||||
return acc;
|
||||
},
|
||||
{ nodes: [], edges: [], bounds: { width: 0, height: 0 } } as LayoutResult,
|
||||
);
|
||||
|
||||
const handleWheel = (event: React.WheelEvent) => {
|
||||
event.preventDefault();
|
||||
setState((draft) => {
|
||||
const nextScale = Math.min(2, Math.max(0.2, draft.scale - event.deltaY * 0.0015));
|
||||
draft.scale = nextScale;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onWheel={handleWheel}
|
||||
className={[styles.canvas, className].filter(Boolean).join(' ')}
|
||||
style={{ '--mindmap-scale': state.scale } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={styles.scene}
|
||||
style={{
|
||||
width: combined.bounds.width,
|
||||
height: combined.bounds.height,
|
||||
transform: `scale(${state.scale}) translate(${state.translate.x}px, ${state.translate.y}px)`,
|
||||
}}
|
||||
>
|
||||
{combined.nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={[
|
||||
styles.node,
|
||||
state.activeNodeIds.includes(node.id) ? styles.nodeActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
left: node.position.x,
|
||||
top: node.position.y,
|
||||
}}
|
||||
>
|
||||
<div className={styles.title}>{node.data.title}</div>
|
||||
{node.data.tags?.length ? (
|
||||
<div className={styles.tags}>
|
||||
{node.data.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{combined.edges.map((edge) => {
|
||||
const source = combined.nodes.find((n) => n.id === edge.source);
|
||||
const target = combined.nodes.find((n) => n.id === edge.target);
|
||||
if (!source || !target) return null;
|
||||
return (
|
||||
<svg key={edge.id} className={styles.edge}>
|
||||
<line
|
||||
x1={source.position.x + 120}
|
||||
y1={source.position.y + 32}
|
||||
x2={target.position.x}
|
||||
y2={target.position.y + 32}
|
||||
stroke="var(--mindmap-edge-color)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: radial-gradient(circle at top, #f8fafc, #eef2ff);
|
||||
}
|
||||
|
||||
.scene {
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: absolute;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nodeActive {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 15px 35px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tags span {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.edge {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--mindmap-edge-color: #cbd5f5;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { mindmapCommandBus } from '@/lib/mindmap/command/MindmapCommandBus';
|
||||
|
||||
type ShortcutHandler = () => void;
|
||||
|
||||
const shortcutMap = new Map<string, ShortcutHandler[]>([
|
||||
['Enter', [() => mindmapCommandBus.emit('INSERT_CHILD_NODE', { parentId: '' })]],
|
||||
['Delete', [() => mindmapCommandBus.emit('REMOVE_NODE', { nodeId: '' })]],
|
||||
]);
|
||||
|
||||
let activeInstance: string | null = null;
|
||||
|
||||
export interface MindmapShortcutControllerProps {
|
||||
instanceId: string;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
export function MindmapShortcutController({ instanceId, enable = true }: MindmapShortcutControllerProps) {
|
||||
useEffect(() => {
|
||||
if (!enable) return;
|
||||
const onKeyDownCapture = (event: KeyboardEvent) => {
|
||||
if (activeInstance && activeInstance !== instanceId) return;
|
||||
if (!activeInstance) activeInstance = instanceId;
|
||||
const combo = getKeyCombo(event);
|
||||
const handlers = shortcutMap.get(combo);
|
||||
if (!handlers) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handlers.forEach((handler) => handler());
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDownCapture, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDownCapture, true);
|
||||
if (activeInstance === instanceId) {
|
||||
activeInstance = null;
|
||||
}
|
||||
};
|
||||
}, [instanceId, enable]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerShortcut(combo: string, handler: ShortcutHandler) {
|
||||
const existing = shortcutMap.get(combo) ?? [];
|
||||
existing.push(handler);
|
||||
shortcutMap.set(combo, existing);
|
||||
}
|
||||
|
||||
function getKeyCombo(event: KeyboardEvent) {
|
||||
const parts: string[] = [];
|
||||
if (event.ctrlKey || event.metaKey) parts.push('Control');
|
||||
if (event.altKey) parts.push('Alt');
|
||||
if (event.shiftKey) parts.push('Shift');
|
||||
parts.push(event.key.length === 1 ? event.key.toUpperCase() : event.key);
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import styles from './mindmap-text-editor.module.css';
|
||||
|
||||
export interface MindmapTextEditorProps {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
onBlur?(): void;
|
||||
}
|
||||
|
||||
export function MindmapTextEditor({ value, onChange, onBlur }: MindmapTextEditorProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && ref.current.innerText !== value) {
|
||||
ref.current.innerText = value;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleInput = () => {
|
||||
if (!ref.current) return;
|
||||
onChange(ref.current.innerText);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.editor}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={handleInput}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.editor {
|
||||
min-width: 160px;
|
||||
min-height: 48px;
|
||||
outline: none;
|
||||
border-radius: 16px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.6);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { mindmapCommandBus } from '@/lib/mindmap/command/MindmapCommandBus';
|
||||
|
||||
export function useMindmapTextEdit(nodeId: string) {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string) => {
|
||||
setValue(next);
|
||||
mindmapCommandBus.emit('SET_NODE_TEXT', { nodeId, text: next });
|
||||
},
|
||||
[nodeId],
|
||||
);
|
||||
|
||||
return { value, setValue, handleChange };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
interface FileTreeProps {
|
||||
nodes: DocumentNode[];
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onOpenDocument: (id: string) => void;
|
||||
onOpenAsset: (asset: MediaAsset) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||||
}
|
||||
|
||||
const INDENT = 16;
|
||||
|
||||
export function FileTree({
|
||||
nodes,
|
||||
assetsByDoc,
|
||||
expanded,
|
||||
activeId,
|
||||
onToggleExpand,
|
||||
onOpenDocument,
|
||||
onOpenAsset,
|
||||
onCreateChild,
|
||||
onContextMenu,
|
||||
}: FileTreeProps) {
|
||||
if (nodes.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-[#f4f4f5]">
|
||||
{nodes.map((node) => (
|
||||
<FileTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
assetsByDoc={assetsByDoc}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onOpenDocument={onOpenDocument}
|
||||
onOpenAsset={onOpenAsset}
|
||||
onCreateChild={onCreateChild}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileTreeNodeProps {
|
||||
node: DocumentNode;
|
||||
depth: number;
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onOpenDocument: (id: string) => void;
|
||||
onOpenAsset: (asset: MediaAsset) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
node,
|
||||
depth,
|
||||
assetsByDoc,
|
||||
expanded,
|
||||
activeId,
|
||||
onToggleExpand,
|
||||
onOpenDocument,
|
||||
onOpenAsset,
|
||||
onCreateChild,
|
||||
onContextMenu,
|
||||
}: FileTreeNodeProps) {
|
||||
const isExpanded = expanded.has(node.id);
|
||||
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
|
||||
const hasChildren = node.children.length > 0 || assets.length > 0;
|
||||
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]",
|
||||
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
)}
|
||||
style={{ paddingLeft: depth * INDENT + 8 }}
|
||||
onContextMenu={(event) => onContextMenu(event, node)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
onClick={() => onOpenDocument(node.id)}
|
||||
>
|
||||
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||||
<span className="truncate">{node.title || "无标题"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建子页面"
|
||||
onClick={() => onCreateChild(node.id)}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-0.5">
|
||||
<FileLeafRow
|
||||
depth={depth + 1}
|
||||
label="index.md"
|
||||
icon={<FileText className="h-4 w-4 text-gray-500" />}
|
||||
onClick={() => onOpenDocument(node.id)}
|
||||
/>
|
||||
{assets.map((asset) => (
|
||||
<FileLeafRow
|
||||
key={asset.id}
|
||||
depth={depth + 1}
|
||||
label={asset.file_name || "附件"}
|
||||
icon={<Paperclip className="h-4 w-4 text-gray-500" />}
|
||||
onClick={() => onOpenAsset(asset)}
|
||||
/>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
assetsByDoc={assetsByDoc}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onOpenDocument={onOpenDocument}
|
||||
onOpenAsset={onOpenAsset}
|
||||
onCreateChild={onCreateChild}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileLeafRow({
|
||||
depth,
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
depth: number;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]"
|
||||
style={{ paddingLeft: depth * INDENT + 32 }}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,8 @@ import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { supabaseBrowser } from "@/lib/supabase/client";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
const TOP_BUTTONS = [
|
||||
{ id: "search", icon: SearchIcon, label: "搜索" },
|
||||
@@ -74,7 +76,9 @@ interface ContextMenuState {
|
||||
export function Sidebar({ initialData }: SidebarProps) {
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
|
||||
useSidebarStore();
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
const viewMode = useSidebarStore((state) => state.viewMode);
|
||||
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
const sidebarQuery = useSidebarData(initialData);
|
||||
const sidebarData = sidebarQuery.data ?? initialData;
|
||||
const segments = useSelectedLayoutSegments();
|
||||
@@ -148,6 +152,18 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
);
|
||||
}, [sidebarData.trashedDocuments, trashSearch]);
|
||||
|
||||
const assetsByDoc = useMemo(() => {
|
||||
const map: Record<string, MediaAsset[]> = {};
|
||||
const assets = sidebarData.mediaAssets ?? [];
|
||||
assets.forEach((asset) => {
|
||||
if (!map[asset.document_id]) {
|
||||
map[asset.document_id] = [];
|
||||
}
|
||||
map[asset.document_id].push(asset);
|
||||
});
|
||||
return map;
|
||||
}, [sidebarData.mediaAssets]);
|
||||
|
||||
const activeWorkspace =
|
||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||
sidebarData.workspaces[0];
|
||||
@@ -231,6 +247,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
||||
const url = asset.signed_url ?? asset.file_url;
|
||||
if (!url) {
|
||||
window.alert("暂无可用的文件链接");
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -577,74 +604,120 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
<Library className="h-4 w-4" />
|
||||
页面树
|
||||
</div>
|
||||
<Input
|
||||
className="mt-2 h-8 rounded-md border-[#eeeeee]"
|
||||
placeholder="搜索页面"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-md border-[#eeeeee]"
|
||||
placeholder="搜索页面"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
/>
|
||||
<div className="flex rounded-md border border-[#e5e7eb] bg-white">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs",
|
||||
viewMode === "section" ? "bg-[#2563eb] text-white" : "text-gray-600",
|
||||
)}
|
||||
onClick={() => setViewMode("section")}
|
||||
>
|
||||
分组
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs",
|
||||
viewMode === "filesystem" ? "bg-[#2563eb] text-white" : "text-gray-600",
|
||||
)}
|
||||
onClick={() => setViewMode("filesystem")}
|
||||
>
|
||||
文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionList
|
||||
id="starred"
|
||||
label="星标置顶"
|
||||
icon={SECTION_ICONS.starred}
|
||||
nodes={starredNodes}
|
||||
collapsed={collapsedSections.starred}
|
||||
onToggle={() => toggleSection("starred")}
|
||||
/>
|
||||
<SectionList
|
||||
id="public"
|
||||
label="公共页面"
|
||||
icon={SECTION_ICONS.public}
|
||||
nodes={publicNodes}
|
||||
collapsed={collapsedSections.public}
|
||||
onToggle={() => toggleSection("public")}
|
||||
/>
|
||||
<SectionList
|
||||
id="shared"
|
||||
label="共享页面"
|
||||
icon={SECTION_ICONS.shared}
|
||||
nodes={sharedNodes}
|
||||
collapsed={collapsedSections.shared}
|
||||
onToggle={() => toggleSection("shared")}
|
||||
/>
|
||||
<SectionList
|
||||
id="templates"
|
||||
label="模板中心"
|
||||
icon={SECTION_ICONS.templates}
|
||||
nodes={templateNodes}
|
||||
collapsed={collapsedSections.templates}
|
||||
onToggle={() => toggleSection("templates")}
|
||||
/>
|
||||
{viewMode === "section" ? (
|
||||
<>
|
||||
<SectionList
|
||||
id="starred"
|
||||
label="星标置顶"
|
||||
icon={SECTION_ICONS.starred}
|
||||
nodes={starredNodes}
|
||||
collapsed={collapsedSections.starred}
|
||||
onToggle={() => toggleSection("starred")}
|
||||
/>
|
||||
<SectionList
|
||||
id="public"
|
||||
label="公共页面"
|
||||
icon={SECTION_ICONS.public}
|
||||
nodes={publicNodes}
|
||||
collapsed={collapsedSections.public}
|
||||
onToggle={() => toggleSection("public")}
|
||||
/>
|
||||
<SectionList
|
||||
id="shared"
|
||||
label="共享页面"
|
||||
icon={SECTION_ICONS.shared}
|
||||
nodes={sharedNodes}
|
||||
collapsed={collapsedSections.shared}
|
||||
onToggle={() => toggleSection("shared")}
|
||||
/>
|
||||
<SectionList
|
||||
id="templates"
|
||||
label="模板中心"
|
||||
icon={SECTION_ICONS.templates}
|
||||
nodes={templateNodes}
|
||||
collapsed={collapsedSections.templates}
|
||||
onToggle={() => toggleSection("templates")}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
|
||||
onClick={() => toggleSection("private")}
|
||||
>
|
||||
<span>私有 / 我的页面</span>
|
||||
<MoreHorizontal className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
{!collapsedSections.private ? (
|
||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
|
||||
onClick={() => toggleSection("private")}
|
||||
>
|
||||
<span>私有 / 我的页面</span>
|
||||
<MoreHorizontal className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
{!collapsedSections.private ? (
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
nodes={filteredPrivateTree}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
<FileTree
|
||||
nodes={filteredPrivateTree}
|
||||
assetsByDoc={assetsByDoc}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onOpenDocument={(id) => handleOpenDocument(id, "main")}
|
||||
onOpenAsset={handleOpenAsset}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-[#f1f1f1] p-3">
|
||||
<Button className="w-full justify-center gap-2" variant="outline" onClick={() => handleCreate(null)}>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
type Handler<T = unknown> = (payload: T) => void;
|
||||
|
||||
export interface CommandMap {
|
||||
INSERT_NODE: { parentId?: string };
|
||||
INSERT_CHILD_NODE: { parentId: string };
|
||||
REMOVE_NODE: { nodeId: string };
|
||||
SET_NODE_TEXT: { nodeId: string; text: string };
|
||||
SET_NODE_LINK: { nodeId: string; link: unknown };
|
||||
}
|
||||
|
||||
export type CommandName = keyof CommandMap;
|
||||
|
||||
export class MindmapCommandBus {
|
||||
private handlers: { [K in CommandName]?: Set<Handler<CommandMap[K]>> } = {};
|
||||
|
||||
on<K extends CommandName>(command: K, handler: Handler<CommandMap[K]>) {
|
||||
if (!this.handlers[command]) {
|
||||
this.handlers[command] = new Set();
|
||||
}
|
||||
this.handlers[command]!.add(handler);
|
||||
return () => this.off(command, handler);
|
||||
}
|
||||
|
||||
off<K extends CommandName>(command: K, handler: Handler<CommandMap[K]>) {
|
||||
this.handlers[command]?.delete(handler);
|
||||
}
|
||||
|
||||
emit<K extends CommandName>(command: K, payload: CommandMap[K]) {
|
||||
this.handlers[command]?.forEach((handler) => handler(payload));
|
||||
}
|
||||
}
|
||||
|
||||
export const mindmapCommandBus = new MindmapCommandBus();
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
LayoutEngine,
|
||||
LayoutOptions,
|
||||
LayoutResult,
|
||||
LayoutName,
|
||||
MindmapTree,
|
||||
} from '@/lib/mindmap/types';
|
||||
import { LogicalStructureLayout } from '@/lib/mindmap/layout/engines/logicalStructure';
|
||||
import { MindLayout } from '@/lib/mindmap/layout/engines/mindMap';
|
||||
|
||||
type EngineRegistry = Record<LayoutName, LayoutEngine>;
|
||||
|
||||
export class MindLayoutEngine {
|
||||
private engines: EngineRegistry;
|
||||
private defaultOptions: LayoutOptions;
|
||||
|
||||
constructor(options: LayoutOptions = {}) {
|
||||
this.defaultOptions = options;
|
||||
this.engines = {
|
||||
logical: new LogicalStructureLayout(),
|
||||
mind: new MindLayout(),
|
||||
radial: new LogicalStructureLayout(),
|
||||
timeline: new LogicalStructureLayout(),
|
||||
};
|
||||
}
|
||||
|
||||
register(name: LayoutName, engine: LayoutEngine) {
|
||||
this.engines[name] = engine;
|
||||
}
|
||||
|
||||
compute(tree: MindmapTree, layout: LayoutName = 'logical', options?: LayoutOptions): LayoutResult {
|
||||
const engine = this.engines[layout] ?? this.engines.logical;
|
||||
const mergedOptions = { ...this.defaultOptions, ...options };
|
||||
return engine.compute(tree, mergedOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
LayoutEngine,
|
||||
LayoutNode,
|
||||
LayoutOptions,
|
||||
LayoutResult,
|
||||
MindmapNode,
|
||||
MindmapTree,
|
||||
} from '@/lib/mindmap/types';
|
||||
|
||||
interface PositionedNode {
|
||||
node: MindmapNode;
|
||||
depth: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export class LogicalStructureLayout implements LayoutEngine {
|
||||
readonly name = 'logical' as const;
|
||||
|
||||
compute(tree: MindmapTree, options: LayoutOptions = {}): LayoutResult {
|
||||
const horizontalSpacing = options.horizontalSpacing ?? 280;
|
||||
const verticalSpacing = options.verticalSpacing ?? 96;
|
||||
|
||||
const positioned: PositionedNode[] = [];
|
||||
walk(tree, 0, positioned);
|
||||
|
||||
const nodes: LayoutNode[] = positioned.map((item) => ({
|
||||
id: item.node.id,
|
||||
parentId: item.node.parentId,
|
||||
depth: item.depth,
|
||||
data: item.node.data,
|
||||
position: {
|
||||
x: item.depth * horizontalSpacing,
|
||||
y: item.index * verticalSpacing,
|
||||
},
|
||||
}));
|
||||
|
||||
const edges = nodes
|
||||
.filter((n) => n.parentId)
|
||||
.map((n) => ({
|
||||
id: `${n.parentId}-${n.id}`,
|
||||
source: n.parentId!,
|
||||
target: n.id,
|
||||
}));
|
||||
|
||||
const bounds = {
|
||||
width: Math.max(...nodes.map((n) => n.position.x), 0) + horizontalSpacing,
|
||||
height:
|
||||
Math.max(...nodes.map((n) => n.position.y), 0) + verticalSpacing,
|
||||
};
|
||||
|
||||
return { nodes, edges, bounds };
|
||||
}
|
||||
}
|
||||
|
||||
function walk(node: MindmapNode, depth: number, acc: PositionedNode[], nextIndex = { value: 0 }) {
|
||||
acc.push({ node, depth, index: nextIndex.value++ });
|
||||
node.children?.forEach((child) => {
|
||||
child.parentId = node.id;
|
||||
walk(child, depth + 1, acc, nextIndex);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import {
|
||||
LayoutEngine,
|
||||
LayoutOptions,
|
||||
LayoutResult,
|
||||
MindmapTree,
|
||||
} from '@/lib/mindmap/types';
|
||||
import { LogicalStructureLayout } from '@/lib/mindmap/layout/engines/logicalStructure';
|
||||
|
||||
/**
|
||||
* Mind map layout currently reuses the logical structure strategy but keeps a
|
||||
* distinct class so that we can introduce bezier/left-right balancing later.
|
||||
*/
|
||||
export class MindLayout implements LayoutEngine {
|
||||
readonly name = 'mind' as const;
|
||||
private delegate = new LogicalStructureLayout();
|
||||
|
||||
compute(tree: MindmapTree, options?: LayoutOptions): LayoutResult {
|
||||
return this.delegate.compute(tree, {
|
||||
horizontalSpacing: options?.horizontalSpacing ?? 320,
|
||||
verticalSpacing: options?.verticalSpacing ?? 72,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import {
|
||||
MindmapLink,
|
||||
MindmapLinkDocument,
|
||||
MindmapLinkMindNode,
|
||||
MindmapLinkUrl,
|
||||
} from '@/lib/mindmap/types';
|
||||
|
||||
const MINDMAP_PROTOCOL = 'mind://';
|
||||
|
||||
export interface ParseLinkResult {
|
||||
link: MindmapLink | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export function parseLink(input: string): ParseLinkResult {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { link: null, raw: input };
|
||||
}
|
||||
|
||||
return (
|
||||
parseMindmapProtocol(trimmed) ??
|
||||
parseJsonLink(trimmed) ??
|
||||
parsePlainUrl(trimmed) ?? { link: null, raw: input }
|
||||
);
|
||||
}
|
||||
|
||||
function parseMindmapProtocol(text: string): ParseLinkResult | null {
|
||||
if (!text.startsWith(MINDMAP_PROTOCOL)) return null;
|
||||
const fragment = text.slice(MINDMAP_PROTOCOL.length);
|
||||
const [mindmapId, nodeId] = fragment.split('/');
|
||||
if (!mindmapId || !nodeId) return null;
|
||||
const link: MindmapLinkMindNode = {
|
||||
type: 'mindmap-node',
|
||||
mindmapId,
|
||||
nodeId,
|
||||
};
|
||||
return { link, raw: text };
|
||||
}
|
||||
|
||||
function parseJsonLink(text: string): ParseLinkResult | null {
|
||||
try {
|
||||
const payload = JSON.parse(text);
|
||||
if (payload?.type === 'document' && typeof payload.documentId === 'string') {
|
||||
const link: MindmapLinkDocument = {
|
||||
type: 'document',
|
||||
documentId: payload.documentId,
|
||||
title: payload.title,
|
||||
};
|
||||
return { link, raw: text };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePlainUrl(text: string): ParseLinkResult | null {
|
||||
try {
|
||||
const url = new URL(text);
|
||||
const link: MindmapLinkUrl = {
|
||||
type: 'url',
|
||||
url: url.toString(),
|
||||
title: url.hostname,
|
||||
};
|
||||
return { link, raw: text };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeLink(link: MindmapLink | null): string {
|
||||
if (!link) return '';
|
||||
switch (link.type) {
|
||||
case 'document':
|
||||
return JSON.stringify({
|
||||
type: 'document',
|
||||
documentId: link.documentId,
|
||||
title: link.title,
|
||||
});
|
||||
case 'mindmap-node':
|
||||
return `${MINDMAP_PROTOCOL}${link.mindmapId}/${link.nodeId}`;
|
||||
case 'url':
|
||||
return link.url;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Core shared mindmap types used across the Stage S1 implementation.
|
||||
* These types intentionally avoid external library dependencies so that
|
||||
* they can be consumed both on the server (Next.js route handlers) and
|
||||
* the client (React components).
|
||||
*/
|
||||
|
||||
export type MindmapIdentifier = string;
|
||||
|
||||
export type LayoutName =
|
||||
| 'logical'
|
||||
| 'mind'
|
||||
| 'radial'
|
||||
| 'timeline';
|
||||
|
||||
export interface MindmapNodeData {
|
||||
title: string;
|
||||
richText?: string;
|
||||
icon?: string;
|
||||
tags?: string[];
|
||||
link?: MindmapLink | null;
|
||||
attachments?: MindmapAttachment[];
|
||||
collapsed?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MindmapNode {
|
||||
id: MindmapIdentifier;
|
||||
parentId?: MindmapIdentifier;
|
||||
children?: MindmapNode[];
|
||||
data: MindmapNodeData;
|
||||
}
|
||||
|
||||
export type MindmapTree = MindmapNode;
|
||||
|
||||
export interface MindmapLinkBlock {
|
||||
type: 'block';
|
||||
blockId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface MindmapLinkDocument {
|
||||
type: 'document';
|
||||
documentId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface MindmapLinkMindNode {
|
||||
type: 'mindmap-node';
|
||||
mindmapId: string;
|
||||
nodeId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface MindmapLinkPdf {
|
||||
type: 'pdf';
|
||||
path: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface MindmapLinkUrl {
|
||||
type: 'url';
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type MindmapLink =
|
||||
| MindmapLinkBlock
|
||||
| MindmapLinkDocument
|
||||
| MindmapLinkMindNode
|
||||
| MindmapLinkPdf
|
||||
| MindmapLinkUrl;
|
||||
|
||||
export interface MindmapAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LayoutNode {
|
||||
id: MindmapIdentifier;
|
||||
parentId?: MindmapIdentifier;
|
||||
position: { x: number; y: number };
|
||||
depth: number;
|
||||
data: MindmapNodeData;
|
||||
}
|
||||
|
||||
export interface LayoutEdge {
|
||||
id: string;
|
||||
source: MindmapIdentifier;
|
||||
target: MindmapIdentifier;
|
||||
}
|
||||
|
||||
export interface LayoutResult {
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
bounds: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface LayoutOptions {
|
||||
nodeWidth?: number;
|
||||
nodeHeight?: number;
|
||||
horizontalSpacing?: number;
|
||||
verticalSpacing?: number;
|
||||
}
|
||||
|
||||
export interface LayoutEngine {
|
||||
readonly name: LayoutName;
|
||||
compute(tree: MindmapTree, options?: LayoutOptions): LayoutResult;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ interface SidebarState {
|
||||
width: number;
|
||||
collapsedSections: Record<SidebarSectionId, boolean>;
|
||||
trashConfirm: boolean;
|
||||
viewMode: "section" | "filesystem";
|
||||
setOpen: (open: boolean) => void;
|
||||
setWidth: (width: number) => void;
|
||||
toggleSection: (section: SidebarSectionId) => void;
|
||||
setSectionCollapsed: (section: SidebarSectionId, collapsed: boolean) => void;
|
||||
setTrashConfirm: (value: boolean) => void;
|
||||
setViewMode: (mode: SidebarState["viewMode"]) => void;
|
||||
}
|
||||
|
||||
const sectionDefaults: Record<SidebarSectionId, boolean> = {
|
||||
@@ -29,6 +31,7 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
width: 280,
|
||||
collapsedSections: { ...sectionDefaults },
|
||||
trashConfirm: true,
|
||||
viewMode: "section",
|
||||
setOpen: (open) => set({ open }),
|
||||
setWidth: (width) => set({ width }),
|
||||
toggleSection: (section) =>
|
||||
@@ -46,6 +49,7 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
},
|
||||
})),
|
||||
setTrashConfirm: (value) => set({ trashConfirm: value }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "sidebar-ui",
|
||||
@@ -53,6 +57,7 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
width: state.width,
|
||||
collapsedSections: state.collapsedSections,
|
||||
trashConfirm: state.trashConfirm,
|
||||
viewMode: state.viewMode,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export interface MindmapState {
|
||||
scale: number;
|
||||
translate: { x: number; y: number };
|
||||
activeNodeIds: string[];
|
||||
multiRoot: boolean;
|
||||
}
|
||||
|
||||
type Setter<T> = (updater: (state: T) => void) => void;
|
||||
|
||||
function createStore(initialState: MindmapState) {
|
||||
let state = initialState;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const setState: Setter<MindmapState> = (updater) => {
|
||||
updater(state);
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const getSnapshot = () => state;
|
||||
|
||||
const subscribe = (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
return { setState, getSnapshot, subscribe };
|
||||
}
|
||||
|
||||
const store = createStore({
|
||||
scale: 1,
|
||||
translate: { x: 0, y: 0 },
|
||||
activeNodeIds: [],
|
||||
multiRoot: false,
|
||||
});
|
||||
|
||||
export function useMindmapStore<T>(selector: (state: MindmapState) => T): [T, Setter<MindmapState>] {
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
return [selector(snapshot), store.setState];
|
||||
}
|
||||
|
||||
@@ -5,11 +5,16 @@ export interface MediaAsset {
|
||||
asset_type: string;
|
||||
file_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
bucket?: string | null;
|
||||
storage_path?: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
ocr_text: string | null;
|
||||
ocr_status: string | null;
|
||||
ocr_payload?: unknown;
|
||||
ocr_strategy?: string | null;
|
||||
signed_url?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -288,9 +288,13 @@ export type Database = {
|
||||
asset_type: string;
|
||||
file_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
bucket: string | null;
|
||||
storage_path: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
ocr_payload: Json | null;
|
||||
ocr_strategy: string | null;
|
||||
ocr_text: string | null;
|
||||
ocr_status: string | null;
|
||||
created_by: string | null;
|
||||
@@ -304,9 +308,13 @@ export type Database = {
|
||||
asset_type?: string;
|
||||
file_url?: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
bucket?: string | null;
|
||||
storage_path?: string | null;
|
||||
file_name?: string | null;
|
||||
file_size?: number | null;
|
||||
mime_type?: string | null;
|
||||
ocr_payload?: Json | null;
|
||||
ocr_strategy?: string | null;
|
||||
ocr_text?: string | null;
|
||||
ocr_status?: string | null;
|
||||
created_by?: string | null;
|
||||
@@ -320,9 +328,13 @@ export type Database = {
|
||||
asset_type?: string;
|
||||
file_url?: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
bucket?: string | null;
|
||||
storage_path?: string | null;
|
||||
file_name?: string | null;
|
||||
file_size?: number | null;
|
||||
mime_type?: string | null;
|
||||
ocr_payload?: Json | null;
|
||||
ocr_strategy?: string | null;
|
||||
ocr_text?: string | null;
|
||||
ocr_status?: string | null;
|
||||
created_by?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user