chore: save snapshot before tag 0.3

This commit is contained in:
liaibo
2025-12-27 20:23:35 +08:00
parent 9bd5c9c403
commit c0b7b40eee
508 changed files with 2097 additions and 356976 deletions
View File
@@ -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 };
}
+195
View File
@@ -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>
);
}
+128 -55
View File
@@ -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)}>