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)
|
- [x] 节点右键创建子文档,触发 Supabase RPC 并写回导图节点链接(节点扩展面板新增“创建子文档并绑定”按钮,调用 `/api/documents/create-child` 并自动写入 hyperlink)
|
||||||
|
|
||||||
## 阶段 4:高级功能对齐
|
## 阶段 4:高级功能对齐
|
||||||
- [ ] 支持导图/节点镜像块(参考 KMind v2.7.0),确保主导图变更实时同步到 BlockNote 镜像
|
- [x] 支持导图/节点镜像块(参考 KMind v2.7.0),确保主导图变更实时同步到 BlockNote 镜像
|
||||||
- [ ] 实现全局配置面板(鼠标模式、默认主题/结构、自动禅模式等),设置保存在用户配置表
|
- [x] 实现全局配置面板(鼠标模式、默认主题/结构、自动禅模式等),设置保存在用户配置表
|
||||||
- [ ] 当前状态:全局配置已在客户端持久化并应用到实例,需补充云端存储和多端同步
|
- [x] 当前状态:全局配置已在客户端持久化并应用到实例,补充 Supabase 云端存储与多端实时同步
|
||||||
- [ ] 迁移 KMind 主题设计器与分享功能,适配括号连线、彩虹线条、主题导入导出 API
|
- [ ] 迁移 KMind 主题设计器与分享功能,适配括号连线、彩虹线条、主题导入导出 API
|
||||||
- [ ] 补充 “一键转导图插入文档树”“Freemind 导入导出”等增值功能
|
- [ ] 补充 “一键转导图插入文档树”“Freemind 导入导出”等增值功能
|
||||||
|
|
||||||
## 阶段 5:协作、历史、性能与质检
|
## 阶段 5:协作、历史、性能与质检
|
||||||
- [ ] 接入 Yjs + Hocuspocus/Supabase Realtime,实现节点数据与视图状态(zoom/pan)协同,包含冲突合并策略
|
- [x] 接入 Yjs + Hocuspocus/Supabase Realtime,实现节点数据与视图状态(zoom/pan)协同,包含冲突合并策略
|
||||||
- [ ] 仿 KMind v2.5.0 实现历史版本/兜底保护(定时快照、异常写入拦截、恢复 UI)
|
- [ ] 仿 KMind v2.5.0 实现历史版本/兜底保护(定时快照、异常写入拦截、恢复 UI)
|
||||||
- [x] 本地兜底:前端记录最多 15 条本地快照,支持下拉恢复/清空(先于云端协同/版本库)
|
- [x] 本地兜底:前端记录最多 15 条本地快照,支持下拉恢复/清空(先于云端协同/版本库)
|
||||||
- [ ] 针对大规模导图加入虚拟化、懒加载、Worker 分流,确保首屏 < 1s 并输出性能基准报告
|
- [ ] 针对大规模导图加入虚拟化、懒加载、Worker 分流,确保首屏 < 1s 并输出性能基准报告(右侧大纲已虚拟化,画布/导出仍需分流)
|
||||||
- [ ] 构建端到端自动化测试链(拖拽节点→保存→嵌入同步→链接跳转)与视觉回归/压力测试
|
- [ ] 构建端到端自动化测试链(拖拽节点→保存→嵌入同步→链接跳转)与视觉回归/压力测试
|
||||||
|
|
||||||
## UI 对齐行动计划(对标 https://wanglin2.github.io/mind-map/#/)
|
## 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,
|
useState,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||||
|
import * as Y from 'yjs';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -47,7 +50,6 @@ import {
|
|||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from '@/components/ui/drawer';
|
} from '@/components/ui/drawer';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_MINDMAP_SNAPSHOT,
|
DEFAULT_MINDMAP_SNAPSHOT,
|
||||||
type MindmapNode,
|
type MindmapNode,
|
||||||
@@ -170,6 +172,14 @@ type SnapshotHistoryEntry = {
|
|||||||
viewSignature: string;
|
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) =>
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
Math.min(Math.max(value, min), max);
|
Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
@@ -248,14 +258,23 @@ export function MindmapLabClient({
|
|||||||
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||||
const [userConfig, setUserConfig] = useState<MindmapGlobalConfig>(DEFAULT_GLOBAL_CONFIG);
|
const [userConfig, setUserConfig] = useState<MindmapGlobalConfig>(DEFAULT_GLOBAL_CONFIG);
|
||||||
const [historyEntries, setHistoryEntries] = useState<SnapshotHistoryEntry[]>([]);
|
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 [sideTab, setSideTab] = useState('inspector');
|
||||||
const [outlinePreview, setOutlinePreview] = useState<MindmapOutlineEntry | null>(null);
|
const [outlinePreview, setOutlinePreview] = useState<MindmapOutlineEntry | null>(null);
|
||||||
const mindMapRef = useRef<MindMapInstance | null>(null);
|
const mindMapRef = useRef<MindMapInstance | null>(null);
|
||||||
const pendingSnapshotRef = useRef<MindmapSnapshot>(snapshot);
|
const pendingSnapshotRef = useRef<MindmapSnapshot>(snapshot);
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const configSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const lastFocusedNodeRef = useRef<string | 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(() => {
|
const resolvedDocumentTitle = useMemo(() => {
|
||||||
if (typeof documentTitle === 'string' && documentTitle.trim()) {
|
if (typeof documentTitle === 'string' && documentTitle.trim()) {
|
||||||
return documentTitle.trim();
|
return documentTitle.trim();
|
||||||
@@ -271,21 +290,48 @@ export function MindmapLabClient({
|
|||||||
pendingSnapshotRef.current = snapshot;
|
pendingSnapshotRef.current = snapshot;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(USER_CONFIG_STORAGE_KEY);
|
const raw = window.localStorage.getItem(USER_CONFIG_STORAGE_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as MindmapGlobalConfig;
|
const parsed = JSON.parse(raw) as MindmapGlobalConfig;
|
||||||
setUserConfig({
|
setUserConfig((prev) => ({
|
||||||
...DEFAULT_GLOBAL_CONFIG,
|
...prev,
|
||||||
...parsed,
|
...parsed,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载全局思维导图配置失败', error);
|
console.error('加载全局思维导图配置失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
setConfigLoaded(true);
|
setLocalConfigHydrated(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -305,7 +351,7 @@ export function MindmapLabClient({
|
|||||||
}, [historyStorageKey]);
|
}, [historyStorageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!configLoaded || typeof window === 'undefined') return;
|
if (!localConfigHydrated || typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
USER_CONFIG_STORAGE_KEY,
|
USER_CONFIG_STORAGE_KEY,
|
||||||
@@ -314,7 +360,103 @@ export function MindmapLabClient({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存全局思维导图配置失败', 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(() => {
|
useEffect(() => {
|
||||||
if (!focusNodeId || !mindMapRef.current) {
|
if (!focusNodeId || !mindMapRef.current) {
|
||||||
@@ -425,6 +567,102 @@ export function MindmapLabClient({
|
|||||||
persistHistory(initialSanitized, snapshotSignatureRef.current);
|
persistHistory(initialSanitized, snapshotSignatureRef.current);
|
||||||
}, [initialSanitized, persistHistory]);
|
}, [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(
|
const handleSnapshotChange = useCallback(
|
||||||
(nextSnapshot: MindmapSnapshot) => {
|
(nextSnapshot: MindmapSnapshot) => {
|
||||||
const sanitized = scrubRichTextSnapshot(nextSnapshot);
|
const sanitized = scrubRichTextSnapshot(nextSnapshot);
|
||||||
@@ -445,6 +683,12 @@ export function MindmapLabClient({
|
|||||||
view: viewSignature,
|
view: viewSignature,
|
||||||
};
|
};
|
||||||
pendingSnapshotRef.current = sanitized;
|
pendingSnapshotRef.current = sanitized;
|
||||||
|
if (applyingCollaborativeRemoteRef.current) {
|
||||||
|
if (initialDocumentId) {
|
||||||
|
scheduleSave(sanitized);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (structureChanged) {
|
if (structureChanged) {
|
||||||
setSnapshot(sanitized);
|
setSnapshot(sanitized);
|
||||||
setSelectedLayout(
|
setSelectedLayout(
|
||||||
@@ -461,8 +705,12 @@ export function MindmapLabClient({
|
|||||||
if (initialDocumentId) {
|
if (initialDocumentId) {
|
||||||
scheduleSave(sanitized);
|
scheduleSave(sanitized);
|
||||||
}
|
}
|
||||||
|
publishCollaborativeSnapshot(sanitized, {
|
||||||
|
structure: structureSignature,
|
||||||
|
view: viewSignature,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[initialDocumentId, persistHistory, scheduleSave]
|
[initialDocumentId, persistHistory, publishCollaborativeSnapshot, scheduleSave]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInstanceReady = useCallback((instance: MindMapInstance) => {
|
const handleInstanceReady = useCallback((instance: MindMapInstance) => {
|
||||||
@@ -599,6 +847,13 @@ export function MindmapLabClient({
|
|||||||
const selectedNodeText = toPlainText(
|
const selectedNodeText = toPlainText(
|
||||||
primaryNodeData?.text ?? snapshot.root?.data?.text ?? ''
|
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(() => {
|
const primaryStylePayload = useMemo(() => {
|
||||||
if (!primaryNode || typeof primaryNode.getStyle !== 'function') {
|
if (!primaryNode || typeof primaryNode.getStyle !== 'function') {
|
||||||
return {
|
return {
|
||||||
@@ -890,14 +1145,15 @@ export function MindmapLabClient({
|
|||||||
}, [containerEl, updateAnchorRect]);
|
}, [containerEl, updateAnchorRect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!configLoaded) return;
|
if (!localConfigHydrated && !remoteConfigHydrated) return;
|
||||||
const instance = mindMapRef.current;
|
const instance = mindMapRef.current;
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
instance.opt.mousewheelAction = userConfig.mouseWheelAction;
|
instance.opt.mousewheelAction = userConfig.mouseWheelAction;
|
||||||
instance.setLayout(userConfig.defaultLayout);
|
instance.setLayout(userConfig.defaultLayout);
|
||||||
instance.setTheme(userConfig.defaultTheme);
|
instance.setTheme(userConfig.defaultTheme);
|
||||||
}, [
|
}, [
|
||||||
configLoaded,
|
localConfigHydrated,
|
||||||
|
remoteConfigHydrated,
|
||||||
userConfig.defaultLayout,
|
userConfig.defaultLayout,
|
||||||
userConfig.defaultTheme,
|
userConfig.defaultTheme,
|
||||||
userConfig.mouseWheelAction,
|
userConfig.mouseWheelAction,
|
||||||
@@ -1018,6 +1274,19 @@ export function MindmapLabClient({
|
|||||||
toast.success('已清空本地快照');
|
toast.success('已清空本地快照');
|
||||||
}, [historyStorageKey]);
|
}, [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 historyLabel = useCallback((entry: SnapshotHistoryEntry) => {
|
||||||
const date = new Date(entry.createdAt);
|
const date = new Date(entry.createdAt);
|
||||||
return `${date.getMonth() + 1}-${date.getDate()} ${date
|
return `${date.getMonth() + 1}-${date.getDate()} ${date
|
||||||
@@ -1332,6 +1601,15 @@ export function MindmapLabClient({
|
|||||||
<span className="h-2 w-2 rounded-full bg-current" />
|
<span className="h-2 w-2 rounded-full bg-current" />
|
||||||
<span>{SAVE_STATE_LABEL[saveState]}</span>
|
<span>{SAVE_STATE_LABEL[saveState]}</span>
|
||||||
</div>
|
</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">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1743,18 +2021,31 @@ export function MindmapLabClient({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="outline" className="flex-1 py-4">
|
<TabsContent value="outline" className="flex-1 py-4">
|
||||||
<ScrollArea className="h-full pr-2">
|
<div
|
||||||
<div className="space-y-3">
|
ref={outlineParentRef}
|
||||||
{outlineEntries.map((entry) => (
|
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
|
<div
|
||||||
key={entry.uid}
|
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"
|
className="rounded-2xl border border-border/60 px-3 py-2"
|
||||||
style={{ marginLeft: entry.depth * 12 }}
|
style={{ marginLeft: entry.depth * 12 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Maximize2 className="h-3 w-3 text-muted-foreground" />
|
<Maximize2 className="h-3 w-3 text-muted-foreground" />
|
||||||
<span>{entry.text}</span>
|
<span className="truncate">{entry.text}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -1777,9 +2068,16 @@ export function MindmapLabClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
);
|
||||||
|
})}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="preview" className="flex-1 py-4">
|
<TabsContent value="preview" className="flex-1 py-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createReactBlockSpec } from '@blocknote/react';
|
import { createReactBlockSpec } from '@blocknote/react';
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
import { Loader2, RefreshCcw, ExternalLink } from 'lucide-react';
|
import { Loader2, RefreshCcw, ExternalLink } from 'lucide-react';
|
||||||
import { KMindRenderer } from '@/components/mindmap/KMindRenderer';
|
import { KMindRenderer } from '@/components/mindmap/KMindRenderer';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MINDMAP_SNAPSHOT,
|
DEFAULT_MINDMAP_SNAPSHOT,
|
||||||
|
type MindmapNode,
|
||||||
type MindmapSnapshot,
|
type MindmapSnapshot,
|
||||||
} from '@/types/mindmap';
|
} from '@/types/mindmap';
|
||||||
import { normalizeSnapshot } from '@/lib/mindmap/snapshot';
|
import { normalizeSnapshot } from '@/lib/mindmap/snapshot';
|
||||||
@@ -16,14 +18,56 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
type MindmapEmbedContentProps = {
|
type MindmapEmbedContentProps = {
|
||||||
mindmapId: string;
|
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 buildMindmapUrl = (mindmapId: string) => `/mindmap/${mindmapId}`;
|
||||||
|
|
||||||
const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
|
const MindmapEmbedContent = ({ mindmapId, focusNodeId }: MindmapEmbedContentProps) => {
|
||||||
const [snapshot, setSnapshot] = useState<MindmapSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<MindmapSnapshot | null>(null);
|
||||||
const [documentTitle, setDocumentTitle] = useState<string>('思维导图');
|
const [documentTitle, setDocumentTitle] = useState<string>('思维导图');
|
||||||
const [loading, setLoading] = useState(false);
|
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 () => {
|
const fetchMindmap = useCallback(async () => {
|
||||||
if (!mindmapId) {
|
if (!mindmapId) {
|
||||||
@@ -41,11 +85,9 @@ const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (data?.mindmap_data) {
|
applySnapshot(
|
||||||
setSnapshot(normalizeSnapshot(data.mindmap_data));
|
data?.mindmap_data ?? { ...DEFAULT_MINDMAP_SNAPSHOT, version: Date.now() }
|
||||||
} else {
|
);
|
||||||
setSnapshot({ ...DEFAULT_MINDMAP_SNAPSHOT, version: Date.now() });
|
|
||||||
}
|
|
||||||
setDocumentTitle(data?.title ?? '思维导图');
|
setDocumentTitle(data?.title ?? '思维导图');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载嵌入导图失败', error);
|
console.error('加载嵌入导图失败', error);
|
||||||
@@ -56,12 +98,49 @@ const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [mindmapId]);
|
}, [applySnapshot, mindmapId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchMindmap();
|
void fetchMindmap();
|
||||||
}, [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(() => {
|
const displayTitle = useMemo(() => {
|
||||||
if (!mindmapId) return '未绑定导图';
|
if (!mindmapId) return '未绑定导图';
|
||||||
if (!documentTitle || !documentTitle.trim()) return '思维导图';
|
if (!documentTitle || !documentTitle.trim()) return '思维导图';
|
||||||
@@ -84,6 +163,21 @@ const MindmapEmbedContent = ({ mindmapId }: MindmapEmbedContentProps) => {
|
|||||||
{displayTitle}
|
{displayTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-[#6b7280]">{mindmapId}</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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -138,12 +232,16 @@ export const mindmapEmbedBlock = createReactBlockSpec(
|
|||||||
title: { default: '' },
|
title: { default: '' },
|
||||||
width: { default: 940 },
|
width: { default: 940 },
|
||||||
height: { default: 360 },
|
height: { default: 360 },
|
||||||
|
focusNodeId: { default: '' },
|
||||||
},
|
},
|
||||||
content: 'none',
|
content: 'none',
|
||||||
},
|
},
|
||||||
() => ({
|
() => ({
|
||||||
render: ({ block }) => (
|
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 referenceBlock = cursor?.block ?? editor.topLevelBlocks[0];
|
||||||
|
|
||||||
const createTableItem: DefaultReactSuggestionItem = {
|
const createTableItem: DefaultReactSuggestionItem = {
|
||||||
|
key: "online_table",
|
||||||
title: "在线表格",
|
title: "在线表格",
|
||||||
group: "高级",
|
group: "高级",
|
||||||
aliases: ["online table", "bg", "表格"],
|
aliases: ["online table", "bg", "表格"],
|
||||||
@@ -165,8 +166,9 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createPageItem: DefaultReactSuggestionItem = {
|
const createPageItem: DefaultReactSuggestionItem = {
|
||||||
|
key: "embed_page_reference",
|
||||||
title: "嵌入页面",
|
title: "嵌入页面",
|
||||||
group: "嵌入",
|
group: "嵌入/子页面",
|
||||||
aliases: ["page", "ym", "子页面", "嵌入页面块"],
|
aliases: ["page", "ym", "子页面", "嵌入页面块"],
|
||||||
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
|
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
|
||||||
onItemClick: async () => {
|
onItemClick: async () => {
|
||||||
@@ -300,8 +302,9 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
|||||||
createPageItem,
|
createPageItem,
|
||||||
createTableItem,
|
createTableItem,
|
||||||
{
|
{
|
||||||
|
key: "embed_mindmap",
|
||||||
title: "思维导图",
|
title: "思维导图",
|
||||||
group: "嵌入",
|
group: "嵌入/导图",
|
||||||
subtext: "嵌入当前页面的只读导图视图",
|
subtext: "嵌入当前页面的只读导图视图",
|
||||||
aliases: ["mindmap", "导图", "siweidaotu"],
|
aliases: ["mindmap", "导图", "siweidaotu"],
|
||||||
icon: <GitBranch className="h-4 w-4 text-[#2563eb]" />,
|
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: {
|
Views: {
|
||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
|
|||||||
Reference in New Issue
Block a user