{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);setRefMenu('');if(e.target===e.currentTarget){setEditing('');select('')};if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
diff --git a/apps/waterfall-wiki/src/pages/workspace.tsx b/apps/waterfall-wiki/src/pages/workspace.tsx
index 4b0494a..b2d5df4 100644
--- a/apps/waterfall-wiki/src/pages/workspace.tsx
+++ b/apps/waterfall-wiki/src/pages/workspace.tsx
@@ -1,8 +1,9 @@
import Head from 'next/head';
import {useEffect, useRef, useState} from 'react';
-import {ArrowLeft, ArrowUpRight, BookOpen, Bot, ChevronDown, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
+import {ArrowLeft, ArrowUpRight, BookOpen, Bot, Check, ChevronDown, ChevronRight, Layers, LogOut, User, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
import {Brand} from '../components/Brand';
import {ThemeToggle} from '../components/ThemeToggle';
+import {parseCanvasDocument, type CanvasNode} from '@/lib/canvas-document';
import s from '@/styles/workspace.module.css';
const tools = [
@@ -17,6 +18,7 @@ const purposes = [
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
];
type Space = {name: string; purpose: string};
+type Subcanvas = {id: string; name: string; nodes: string[]};
const personal = {name: '个人空间', purpose: ''};
export default function Workspace() {
@@ -31,6 +33,13 @@ export default function Workspace() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
+ const [nodes, setNodes] = useState
([]);
+ const [subcanvases, setSubcanvases] = useState([]);
+ const [selectedCanvas, setSelectedCanvas] = useState('');
+ const [expanded, setExpanded] = useState(true);
+ const [subsetName, setSubsetName] = useState('');
+ const [subsetNodes, setSubsetNodes] = useState([]);
+ const subsetDialog = useRef(null);
const dialog = useRef(null);
const switcher = useRef(null);
const search = useRef(null);
@@ -73,6 +82,60 @@ export default function Workspace() {
switcher.current?.removeAttribute('open');
}
+ const subsetKey = 'studio.subcanvases:' + email + ':' + active.name;
+ useEffect(() => {
+ if (!email) return;
+ setSelectedCanvas('');
+ setQuery('');
+ try {
+ const draft = localStorage.getItem('studio.infinite-canvas.v1');
+ setNodes(draft ? parseCanvasDocument(draft).cards : []);
+ } catch {
+ setNodes([]);
+ }
+ try {
+ const saved = JSON.parse(localStorage.getItem(subsetKey) || '[]');
+ setSubcanvases(Array.isArray(saved) ? saved : []);
+ } catch {
+ setSubcanvases([]);
+ }
+ }, [email, subsetKey]);
+
+ function openSubsetDialog(edit?: Subcanvas) {
+ setError('');
+ setSubsetName(edit?.name ?? '');
+ setSubsetNodes(edit?.nodes ?? []);
+ subsetDialog.current?.showModal();
+ }
+
+ function saveSubset(event: React.FormEvent) {
+ event.preventDefault();
+ const name = subsetName.trim();
+ if (!name || !subsetNodes.length) return setError('请输入名称并选择至少一个节点。');
+ const id = selectedCanvas || window.crypto?.randomUUID?.() || String(Date.now());
+ const subset = {id, name, nodes: subsetNodes};
+ const next = [...subcanvases.filter(item => item.id !== id), subset];
+ try {
+ localStorage.setItem(subsetKey, JSON.stringify(next));
+ setSubcanvases(next);
+ setSelectedCanvas(id);
+ setExpanded(true);
+ subsetDialog.current?.close();
+ } catch {
+ setError('浏览器无法保存子画布。');
+ }
+ }
+
+ async function signOut() {
+ try {
+ const response = await fetch('/api/account/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
+ if (!response.ok) throw Error();
+ window.location.assign('/account?next=/workspace');
+ } catch {
+ setNotice('暂时无法退出登录,请重试。');
+ }
+ }
+
function openDialog() {
setStep('purpose');
setPurpose('');
@@ -96,14 +159,17 @@ export default function Workspace() {
}
}
- const visible = tools.filter(tool => (tool.label + tool.detail).includes(query.trim()));
+ const subset = subcanvases.find(item => item.id === selectedCanvas);
+ const visibleNodes = subset ? nodes.filter(node => subset.nodes.includes(node.id)) : nodes;
+ const visibleSubsets = subcanvases.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
+ const canvasName = '无限画布';
return
工作台 · 啡跃
- 工作台{state === 'ready' &&
}
-
-
+ 画布
+
+ {state === 'ready' && }
空间设置保存在此浏览器
}
- {active.name} / 创作概览
+ {active.name} / {subset?.name ?? canvasName}
{state === 'loading' ? 正在打开工作台…
: state === 'error' ? 暂时无法读取账户。 重新登录
:
-
{active.purpose || '个人空间'}
{active.name}
从画布开始整理想法,或打开图形编辑器继续创作。
打开画布
-
-
-
- {!visible.length && 没有找到匹配的工具。
}
+
+
+ {subset?.name ?? canvasName}
+ {subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}
+ {subset ? : 打开画布 }
+
+ 画布内容
+ {visibleNodes.length ? {visibleNodes.map(node =>
{node.title || '未命名节点'}{node.image &&
}{node.text && {node.text}
})}
: {subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}
}
+
+
}
+