git save current version as 0.0.6

This commit is contained in:
liaibo
2025-12-01 18:41:59 +08:00
parent ddff19672b
commit 5db5923535
9 changed files with 558 additions and 61 deletions
+5 -5
View File
@@ -32,17 +32,17 @@
- [x] 节点右键创建子文档,触发 Supabase RPC 并写回导图节点链接(节点扩展面板新增“创建子文档并绑定”按钮,调用 `/api/documents/create-child` 并自动写入 hyperlink
## 阶段 4:高级功能对齐
- [ ] 支持导图/节点镜像块(参考 KMind v2.7.0),确保主导图变更实时同步到 BlockNote 镜像
- [ ] 实现全局配置面板(鼠标模式、默认主题/结构、自动禅模式等),设置保存在用户配置表
- [ ] 当前状态:全局配置已在客户端持久化并应用到实例,补充云端存储多端同步
- [x] 支持导图/节点镜像块(参考 KMind v2.7.0),确保主导图变更实时同步到 BlockNote 镜像
- [x] 实现全局配置面板(鼠标模式、默认主题/结构、自动禅模式等),设置保存在用户配置表
- [x] 当前状态:全局配置已在客户端持久化并应用到实例,补充 Supabase 云端存储多端实时同步
- [ ] 迁移 KMind 主题设计器与分享功能,适配括号连线、彩虹线条、主题导入导出 API
- [ ] 补充 “一键转导图插入文档树”“Freemind 导入导出”等增值功能
## 阶段 5:协作、历史、性能与质检
- [ ] 接入 Yjs + Hocuspocus/Supabase Realtime,实现节点数据与视图状态(zoom/pan)协同,包含冲突合并策略
- [x] 接入 Yjs + Hocuspocus/Supabase Realtime,实现节点数据与视图状态(zoom/pan)协同,包含冲突合并策略
- [ ] 仿 KMind v2.5.0 实现历史版本/兜底保护(定时快照、异常写入拦截、恢复 UI)
- [x] 本地兜底:前端记录最多 15 条本地快照,支持下拉恢复/清空(先于云端协同/版本库)
- [ ] 针对大规模导图加入虚拟化、懒加载、Worker 分流,确保首屏 < 1s 并输出性能基准报告
- [ ] 针对大规模导图加入虚拟化、懒加载、Worker 分流,确保首屏 < 1s 并输出性能基准报告(右侧大纲已虚拟化,画布/导出仍需分流)
- [ ] 构建端到端自动化测试链(拖拽节点→保存→嵌入同步→链接跳转)与视觉回归/压力测试
## UI 对齐行动计划(对标 https://wanglin2.github.io/mind-map/#/
@@ -0,0 +1,29 @@
"级别 0","级别 1","级别 2","级别 3","级别 4"
"摩尔生物产品线","","","",""
"","CDMO","","",""
"","","当前主要内容","",""
"","","","药品杂质的定制合成",""
"","","未来拓展内容","",""
"","","","高级中间体的定制合成",""
"","","同行公司","",""
"","","","爱斯特",""
"","","","伊诺达博",""
"","","","药明康德",""
"","API","","",""
"","","当前主要内容","",""
"","","","满足工厂立项项目",""
"","","未来拓展内容","",""
"","","","CMC定制服务",""
"","新技术","","",""
"","","可拓展内容","",""
"","","","为降低成本的生产新工艺的开发",""
"","","","连续流工艺技术的开发",""
"","","","","硝化/磺化反应"
"","","","","小型CDMO生产"
"","","","绿色安全工艺路线的开发",""
"","","","","固定床氢化"
"","","","","回路反应器氢化"
"","","同行公司","",""
"","","","爱斯特",""
"","","","康宁",""
"","","","重庆斯普瑞",""
1 级别 0 级别 1 级别 2 级别 3 级别 4
2 摩尔生物产品线
3 CDMO
4 当前主要内容
5 药品杂质的定制合成
6 未来拓展内容
7 高级中间体的定制合成
8 同行公司
9 爱斯特
10 伊诺达博
11 药明康德
12 API
13 当前主要内容
14 满足工厂立项项目
15 未来拓展内容
16 CMC定制服务
17 新技术
18 可拓展内容
19 为降低成本的生产新工艺的开发
20 连续流工艺技术的开发
21 硝化/磺化反应
22 小型CDMO生产
23 绿色安全工艺路线的开发
24 固定床氢化
25 回路反应器氢化
26 同行公司
27 爱斯特
28 康宁
29 重庆斯普瑞
Binary file not shown.
@@ -0,0 +1,43 @@
create table if not exists public.mindmap_user_configs (
user_id uuid primary key references public.profiles(id) on delete cascade,
config jsonb not null default '{}'::jsonb,
updated_at timestamptz not null default timezone('utc', now())
);
create or replace function public.set_mindmap_user_configs_updated_at()
returns trigger
language plpgsql
security invoker
as $$
begin
new.updated_at := timezone('utc', now());
return new;
end;
$$;
drop trigger if exists set_mindmap_user_configs_updated_at on public.mindmap_user_configs;
create trigger set_mindmap_user_configs_updated_at
before update on public.mindmap_user_configs
for each row
execute procedure public.set_mindmap_user_configs_updated_at();
alter table public.mindmap_user_configs enable row level security;
drop policy if exists "Select own mindmap config" on public.mindmap_user_configs;
create policy "Select own mindmap config"
on public.mindmap_user_configs
for select
using (auth.uid() = user_id);
drop policy if exists "Insert own mindmap config" on public.mindmap_user_configs;
create policy "Insert own mindmap config"
on public.mindmap_user_configs
for insert
with check (auth.uid() = user_id);
drop policy if exists "Update own mindmap config" on public.mindmap_user_configs;
create policy "Update own mindmap config"
on public.mindmap_user_configs
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
+1
View File
@@ -0,0 +1 @@
@@ -8,6 +8,9 @@ import {
useState,
type ChangeEvent,
} from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { createPortal } from 'react-dom';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
@@ -47,7 +50,6 @@ import {
DrawerTitle,
} from '@/components/ui/drawer';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
DEFAULT_MINDMAP_SNAPSHOT,
type MindmapNode,
@@ -170,6 +172,14 @@ type SnapshotHistoryEntry = {
viewSignature: string;
};
type CollaborationStatus = 'idle' | 'connecting' | 'connected' | 'error';
type MindmapCollaborationRefs = {
doc: Y.Doc;
provider: HocuspocusProvider;
state: Y.Map<unknown>;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
@@ -248,14 +258,23 @@ export function MindmapLabClient({
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [userConfig, setUserConfig] = useState<MindmapGlobalConfig>(DEFAULT_GLOBAL_CONFIG);
const [historyEntries, setHistoryEntries] = useState<SnapshotHistoryEntry[]>([]);
const [configLoaded, setConfigLoaded] = useState(false);
const [localConfigHydrated, setLocalConfigHydrated] = useState(false);
const [remoteConfigHydrated, setRemoteConfigHydrated] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [collaborationStatus, setCollaborationStatus] = useState<CollaborationStatus>('idle');
const [sideTab, setSideTab] = useState('inspector');
const [outlinePreview, setOutlinePreview] = useState<MindmapOutlineEntry | null>(null);
const mindMapRef = useRef<MindMapInstance | null>(null);
const pendingSnapshotRef = useRef<MindmapSnapshot>(snapshot);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const configSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastFocusedNodeRef = useRef<string | null>(null);
const remoteConfigUpdatedRef = useRef<number>(0);
const applyingRemoteConfigRef = useRef(false);
const collaborationRef = useRef<MindmapCollaborationRefs | null>(null);
const applyingCollaborativeRemoteRef = useRef(false);
const outlineParentRef = useRef<HTMLDivElement | null>(null);
const resolvedDocumentTitle = useMemo(() => {
if (typeof documentTitle === 'string' && documentTitle.trim()) {
return documentTitle.trim();
@@ -271,21 +290,48 @@ export function MindmapLabClient({
pendingSnapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
supabaseBrowser.auth
.getSession()
.then(({ data, error }) => {
if (error) {
console.error('获取登录状态失败', error);
return;
}
setUserId(data.session?.user.id ?? null);
if (!data.session) {
setRemoteConfigHydrated(true);
}
})
.catch((error) => console.error('读取登录状态失败', error));
const { data: listener } = supabaseBrowser.auth.onAuthStateChange(
(_event, session) => {
setUserId(session?.user.id ?? null);
if (!session) {
setRemoteConfigHydrated(true);
}
}
);
return () => {
listener?.subscription?.unsubscribe();
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem(USER_CONFIG_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as MindmapGlobalConfig;
setUserConfig({
...DEFAULT_GLOBAL_CONFIG,
setUserConfig((prev) => ({
...prev,
...parsed,
});
}));
}
} catch (error) {
console.error('加载全局思维导图配置失败', error);
} finally {
setConfigLoaded(true);
setLocalConfigHydrated(true);
}
}, []);
@@ -305,7 +351,7 @@ export function MindmapLabClient({
}, [historyStorageKey]);
useEffect(() => {
if (!configLoaded || typeof window === 'undefined') return;
if (!localConfigHydrated || typeof window === 'undefined') return;
try {
window.localStorage.setItem(
USER_CONFIG_STORAGE_KEY,
@@ -314,7 +360,103 @@ export function MindmapLabClient({
} catch (error) {
console.error('保存全局思维导图配置失败', error);
}
}, [configLoaded, userConfig]);
}, [localConfigHydrated, userConfig]);
const applyRemoteConfig = useCallback(
(config: unknown, updatedAt?: string | null) => {
if (!config || typeof config !== 'object') return;
const timestamp = updatedAt ? Date.parse(updatedAt) || Date.now() : Date.now();
if (timestamp < remoteConfigUpdatedRef.current) return;
remoteConfigUpdatedRef.current = timestamp;
applyingRemoteConfigRef.current = true;
setUserConfig((prev) => ({
...DEFAULT_GLOBAL_CONFIG,
...prev,
...(config as Partial<MindmapGlobalConfig>),
}));
},
[]
);
useEffect(() => {
if (!userId) {
setRemoteConfigHydrated(true);
return;
}
let cancelled = false;
const channel = supabaseBrowser
.channel(`mindmap-config-${userId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'mindmap_user_configs',
filter: `user_id=eq.${userId}`,
},
(payload) => {
const record = payload.new as { config?: unknown; updated_at?: string } | null;
if (!record) return;
applyRemoteConfig(record.config, record.updated_at);
}
)
.subscribe();
const loadRemoteConfig = async () => {
try {
const { data, error } = await supabaseBrowser
.from('mindmap_user_configs')
.select('config,updated_at')
.eq('user_id', userId)
.maybeSingle();
if (error) {
throw error;
}
if (!cancelled && data?.config) {
applyRemoteConfig(data.config, data.updated_at);
}
} catch (error) {
console.error('加载云端导图配置失败', error);
} finally {
if (!cancelled) {
setRemoteConfigHydrated(true);
}
}
};
void loadRemoteConfig();
return () => {
cancelled = true;
supabaseBrowser.removeChannel(channel);
};
}, [applyRemoteConfig, userId]);
useEffect(() => {
if (!userId || !localConfigHydrated || !remoteConfigHydrated) return;
if (configSaveTimerRef.current) {
clearTimeout(configSaveTimerRef.current);
}
if (applyingRemoteConfigRef.current) {
applyingRemoteConfigRef.current = false;
return;
}
configSaveTimerRef.current = setTimeout(async () => {
const { error } = await supabaseBrowser
.from('mindmap_user_configs')
.upsert({
user_id: userId,
config: userConfig,
});
if (error) {
console.error('保存云端导图配置失败', error);
return;
}
remoteConfigUpdatedRef.current = Date.now();
}, 400);
return () => {
if (configSaveTimerRef.current) {
clearTimeout(configSaveTimerRef.current);
}
};
}, [localConfigHydrated, remoteConfigHydrated, userConfig, userId]);
useEffect(() => {
if (!focusNodeId || !mindMapRef.current) {
@@ -425,6 +567,102 @@ export function MindmapLabClient({
persistHistory(initialSanitized, snapshotSignatureRef.current);
}, [initialSanitized, persistHistory]);
useEffect(() => {
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
if (!url || !initialDocumentId) {
setCollaborationStatus('idle');
return;
}
setCollaborationStatus('connecting');
const doc = new Y.Doc();
const provider = new HocuspocusProvider({
url,
name: `mindmap.${initialDocumentId}`,
document: doc,
});
const state = doc.getMap<unknown>('mindmap');
collaborationRef.current = { doc, provider, state };
const applyRemoteSnapshot = () => {
const remoteSnapshot = state.get('snapshot') as MindmapSnapshot | undefined;
const signature = state.get('signature') as
| { structure?: string; view?: string }
| undefined;
if (!remoteSnapshot || !signature?.structure) return;
const viewSignature =
signature.view ?? JSON.stringify(remoteSnapshot.view ?? null);
const incomingSignature = `${signature.structure}|${viewSignature}`;
const currentSignature = `${snapshotSignatureRef.current.structure}|${snapshotSignatureRef.current.view}`;
if (incomingSignature === currentSignature) return;
const normalized = scrubRichTextSnapshot(normalizeSnapshot(remoteSnapshot));
applyingCollaborativeRemoteRef.current = true;
snapshotSignatureRef.current = {
structure: signature.structure,
view: viewSignature,
};
pendingSnapshotRef.current = normalized;
setSnapshot(normalized);
setSelectedLayout(
normalized.layout ?? DEFAULT_MINDMAP_SNAPSHOT.layout
);
setSelectedTheme(
normalized.theme?.template ?? DEFAULT_MINDMAP_SNAPSHOT.theme.template
);
if (initialDocumentId) {
scheduleSave(normalized);
}
applyingCollaborativeRemoteRef.current = false;
};
if (!state.get('snapshot')) {
state.set('snapshot', initialSanitized);
state.set('signature', snapshotSignatureRef.current);
} else {
applyRemoteSnapshot();
}
const handleStateUpdate = () => applyRemoteSnapshot();
const handleStatus = ({ status }: { status: string }) => {
if (status === 'connected') {
setCollaborationStatus('connected');
} else if (status === 'disconnected') {
setCollaborationStatus('idle');
} else {
setCollaborationStatus('connecting');
}
};
provider.on('status', handleStatus);
state.observe(handleStateUpdate);
return () => {
state.unobserve(handleStateUpdate);
provider.off('status', handleStatus as never);
provider.destroy();
doc.destroy();
collaborationRef.current = null;
setCollaborationStatus('idle');
};
}, [initialDocumentId, initialSanitized, scheduleSave]);
const publishCollaborativeSnapshot = useCallback(
(
payload: MindmapSnapshot,
signatures: { structure: string; view: string }
) => {
const session = collaborationRef.current;
if (!session) return;
try {
session.state.set('snapshot', payload);
session.state.set('signature', signatures);
} catch (error) {
console.error('广播协同导图失败', error);
}
},
[]
);
const handleSnapshotChange = useCallback(
(nextSnapshot: MindmapSnapshot) => {
const sanitized = scrubRichTextSnapshot(nextSnapshot);
@@ -445,6 +683,12 @@ export function MindmapLabClient({
view: viewSignature,
};
pendingSnapshotRef.current = sanitized;
if (applyingCollaborativeRemoteRef.current) {
if (initialDocumentId) {
scheduleSave(sanitized);
}
return;
}
if (structureChanged) {
setSnapshot(sanitized);
setSelectedLayout(
@@ -461,8 +705,12 @@ export function MindmapLabClient({
if (initialDocumentId) {
scheduleSave(sanitized);
}
publishCollaborativeSnapshot(sanitized, {
structure: structureSignature,
view: viewSignature,
});
},
[initialDocumentId, persistHistory, scheduleSave]
[initialDocumentId, persistHistory, publishCollaborativeSnapshot, scheduleSave]
);
const handleInstanceReady = useCallback((instance: MindMapInstance) => {
@@ -599,6 +847,13 @@ export function MindmapLabClient({
const selectedNodeText = toPlainText(
primaryNodeData?.text ?? snapshot.root?.data?.text ?? ''
);
const outlineVirtualizer = useVirtualizer({
count: outlineEntries.length,
getScrollElement: () => outlineParentRef.current,
estimateSize: () => 88,
overscan: 10,
});
const outlineVirtualItems = outlineVirtualizer.getVirtualItems();
const primaryStylePayload = useMemo(() => {
if (!primaryNode || typeof primaryNode.getStyle !== 'function') {
return {
@@ -890,14 +1145,15 @@ export function MindmapLabClient({
}, [containerEl, updateAnchorRect]);
useEffect(() => {
if (!configLoaded) return;
if (!localConfigHydrated && !remoteConfigHydrated) return;
const instance = mindMapRef.current;
if (!instance) return;
instance.opt.mousewheelAction = userConfig.mouseWheelAction;
instance.setLayout(userConfig.defaultLayout);
instance.setTheme(userConfig.defaultTheme);
}, [
configLoaded,
localConfigHydrated,
remoteConfigHydrated,
userConfig.defaultLayout,
userConfig.defaultTheme,
userConfig.mouseWheelAction,
@@ -1018,6 +1274,19 @@ export function MindmapLabClient({
toast.success('已清空本地快照');
}, [historyStorageKey]);
const collaborationMeta = useMemo(() => {
switch (collaborationStatus) {
case 'connected':
return { label: '协同已连接', className: 'bg-emerald-50 text-emerald-700' };
case 'connecting':
return { label: '协同连接中…', className: 'bg-amber-50 text-amber-700' };
case 'error':
return { label: '协同异常', className: 'bg-destructive/10 text-destructive' };
default:
return { label: '单机模式', className: 'bg-muted text-muted-foreground' };
}
}, [collaborationStatus]);
const historyLabel = useCallback((entry: SnapshotHistoryEntry) => {
const date = new Date(entry.createdAt);
return `${date.getMonth() + 1}-${date.getDate()} ${date
@@ -1324,7 +1593,7 @@ export function MindmapLabClient({
'inline-flex items-center gap-2 rounded-full px-3 py-1 text-[12px] font-medium',
saveState === 'error'
? 'bg-destructive/10 text-destructive'
: saveState === 'saved'
: saveState === 'saved'
? 'bg-emerald-50 text-emerald-700'
: 'bg-muted text-muted-foreground'
)}
@@ -1332,6 +1601,15 @@ export function MindmapLabClient({
<span className="h-2 w-2 rounded-full bg-current" />
<span>{SAVE_STATE_LABEL[saveState]}</span>
</div>
<div
className={cn(
'inline-flex items-center gap-2 rounded-full px-3 py-1 text-[12px] font-medium',
collaborationMeta.className
)}
>
<span className="h-2 w-2 rounded-full bg-current" />
<span>{collaborationMeta.label}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
@@ -1743,43 +2021,63 @@ export function MindmapLabClient({
</div>
</TabsContent>
<TabsContent value="outline" className="flex-1 py-4">
<ScrollArea className="h-full pr-2">
<div className="space-y-3">
{outlineEntries.map((entry) => (
<div
key={entry.uid}
className="rounded-2xl border border-border/60 px-3 py-2"
style={{ marginLeft: entry.depth * 12 }}
>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-foreground">
<Maximize2 className="h-3 w-3 text-muted-foreground" />
<span>{entry.text}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => handleOutlineFocus(entry)}
>
</Button>
<Button
size="sm"
variant="secondary"
className="text-xs"
data-testid={`outline-preview-${entry.uid}`}
onClick={() => handleOutlinePreview(entry)}
>
</Button>
<div
ref={outlineParentRef}
className="h-full overflow-y-auto pr-2"
>
<div
className="relative w-full"
style={{ height: outlineVirtualizer.getTotalSize() }}
>
{outlineVirtualItems.map((virtualRow) => {
const entry = outlineEntries[virtualRow.index];
return (
<div
key={entry.uid}
ref={outlineVirtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<div
className="rounded-2xl border border-border/60 px-3 py-2"
style={{ marginLeft: entry.depth * 12 }}
>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-foreground">
<Maximize2 className="h-3 w-3 text-muted-foreground" />
<span className="truncate">{entry.text}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => handleOutlineFocus(entry)}
>
</Button>
<Button
size="sm"
variant="secondary"
className="text-xs"
data-testid={`outline-preview-${entry.uid}`}
onClick={() => handleOutlinePreview(entry)}
>
</Button>
</div>
</div>
</div>
</div>
);
})}
{outlineEntries.length === 0 && (
<div className="absolute inset-x-0 top-0 rounded-2xl border border-dashed border-border/60 px-3 py-2 text-xs text-muted-foreground">
</div>
))}
)}
</div>
</ScrollArea>
</div>
</TabsContent>
<TabsContent value="preview" className="flex-1 py-4">
<div className="space-y-4">
@@ -1,11 +1,13 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createReactBlockSpec } from '@blocknote/react';
import type { RealtimeChannel } from '@supabase/supabase-js';
import { Loader2, RefreshCcw, ExternalLink } from 'lucide-react';
import { KMindRenderer } from '@/components/mindmap/KMindRenderer';
import {
DEFAULT_MINDMAP_SNAPSHOT,
type MindmapNode,
type MindmapSnapshot,
} from '@/types/mindmap';
import { normalizeSnapshot } from '@/lib/mindmap/snapshot';
@@ -16,14 +18,56 @@ import { toast } from 'sonner';
type MindmapEmbedContentProps = {
mindmapId: string;
focusNodeId?: string;
};
const cloneNode = (node: MindmapNode): MindmapNode =>
JSON.parse(JSON.stringify(node));
const findNodeById = (
node: MindmapNode | undefined,
targetId: string
): MindmapNode | null => {
if (!node) return null;
if (node.uid === targetId) return node;
if (!node.children) return null;
for (const child of node.children) {
const found = findNodeById(child, targetId);
if (found) return found;
}
return null;
};
const buildMirrorSnapshot = (
snapshot: MindmapSnapshot,
focusNodeId?: string | null
): MindmapSnapshot => {
if (!focusNodeId) return snapshot;
const target = findNodeById(snapshot.root as MindmapNode, focusNodeId);
if (!target) return snapshot;
return {
...snapshot,
root: cloneNode(target),
view: null,
};
};
const buildMindmapUrl = (mindmapId: string) => `/mindmap/${mindmapId}`;
const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
const MindmapEmbedContent = ({ mindmapId, focusNodeId }: MindmapEmbedContentProps) => {
const [snapshot, setSnapshot] = useState<MindmapSnapshot | null>(null);
const [documentTitle, setDocumentTitle] = useState<string>('思维导图');
const [loading, setLoading] = useState(false);
const [isRealtimeBound, setIsRealtimeBound] = useState(false);
const channelRef = useRef<RealtimeChannel | null>(null);
const applySnapshot = useCallback(
(raw: unknown) => {
const normalized = normalizeSnapshot(raw);
setSnapshot(buildMirrorSnapshot(normalized, focusNodeId));
},
[focusNodeId]
);
const fetchMindmap = useCallback(async () => {
if (!mindmapId) {
@@ -41,11 +85,9 @@ const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
if (error) {
throw error;
}
if (data?.mindmap_data) {
setSnapshot(normalizeSnapshot(data.mindmap_data));
} else {
setSnapshot({ ...DEFAULT_MINDMAP_SNAPSHOT, version: Date.now() });
}
applySnapshot(
data?.mindmap_data ?? { ...DEFAULT_MINDMAP_SNAPSHOT, version: Date.now() }
);
setDocumentTitle(data?.title ?? '思维导图');
} catch (error) {
console.error('加载嵌入导图失败', error);
@@ -56,12 +98,49 @@ const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
} finally {
setLoading(false);
}
}, [mindmapId]);
}, [applySnapshot, mindmapId]);
useEffect(() => {
void fetchMindmap();
}, [fetchMindmap]);
useEffect(() => {
if (!mindmapId) return;
const channel = supabaseBrowser
.channel(`mindmap-mirror-${mindmapId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'documents',
filter: `id=eq.${mindmapId}`,
},
(payload) => {
const record = payload.new as { mindmap_data?: unknown; title?: string } | null;
if (record?.mindmap_data) {
applySnapshot(record.mindmap_data);
}
if (typeof record?.title === 'string') {
setDocumentTitle(record.title || '思维导图');
}
}
)
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
setIsRealtimeBound(true);
}
});
channelRef.current = channel;
return () => {
setIsRealtimeBound(false);
if (channelRef.current) {
supabaseBrowser.removeChannel(channelRef.current);
channelRef.current = null;
}
};
}, [applySnapshot, mindmapId]);
const displayTitle = useMemo(() => {
if (!mindmapId) return '未绑定导图';
if (!documentTitle || !documentTitle.trim()) return '思维导图';
@@ -84,6 +163,21 @@ const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
{displayTitle}
</p>
<p className="truncate text-xs text-[#6b7280]">{mindmapId}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-[#f3f4f6] px-2 py-0.5 text-[11px] text-[#111827]">
{focusNodeId ? `镜像节点 ${focusNodeId}` : '全图镜像'}
</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-[11px]',
isRealtimeBound
? 'bg-emerald-50 text-emerald-700'
: 'bg-[#f9fafb] text-[#6b7280]'
)}
>
{isRealtimeBound ? '实时同步中' : '等待主导图推送'}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
@@ -138,12 +232,16 @@ export const mindmapEmbedBlock = createReactBlockSpec(
title: { default: '' },
width: { default: 940 },
height: { default: 360 },
focusNodeId: { default: '' },
},
content: 'none',
},
() => ({
render: ({ block }) => (
<MindmapEmbedContent mindmapId={block.props.mindmapId} />
<MindmapEmbedContent
mindmapId={block.props.mindmapId}
focusNodeId={block.props.focusNodeId}
/>
),
})
)();
@@ -138,6 +138,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[0];
const createTableItem: DefaultReactSuggestionItem = {
key: "online_table",
title: "在线表格",
group: "高级",
aliases: ["online table", "bg", "表格"],
@@ -165,8 +166,9 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
};
const createPageItem: DefaultReactSuggestionItem = {
key: "embed_page_reference",
title: "嵌入页面",
group: "嵌入",
group: "嵌入/子页面",
aliases: ["page", "ym", "子页面", "嵌入页面块"],
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
onItemClick: async () => {
@@ -300,8 +302,9 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
createPageItem,
createTableItem,
{
key: "embed_mindmap",
title: "思维导图",
group: "嵌入",
group: "嵌入/导图",
subtext: "嵌入当前页面的只读导图视图",
aliases: ["mindmap", "导图", "siweidaotu"],
icon: <GitBranch className="h-4 w-4 text-[#2563eb]" />,
+25
View File
@@ -393,6 +393,31 @@ export type Database = {
},
];
};
mindmap_user_configs: {
Row: {
user_id: string;
config: Json;
updated_at: string;
};
Insert: {
user_id: string;
config?: Json;
updated_at?: string;
};
Update: {
user_id?: string;
config?: Json;
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "mindmap_user_configs_user_id_fkey";
columns: ["user_id"];
referencedRelation: "profiles";
referencedColumns: ["id"];
},
];
};
};
Views: {
[_ in never]: never;