git save current version as 0.0.6
This commit is contained in:
@@ -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生产"
|
||||
"","","","绿色安全工艺路线的开发",""
|
||||
"","","","","固定床氢化"
|
||||
"","","","","回路反应器氢化"
|
||||
"","","同行公司","",""
|
||||
"","","","爱斯特",""
|
||||
"","","","康宁",""
|
||||
"","","","重庆斯普瑞",""
|
||||
|
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);
|
||||
@@ -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]" />,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user