diff --git a/apps/waterfall-wiki/src/components/WorkspaceShell.tsx b/apps/waterfall-wiki/src/components/WorkspaceShell.tsx new file mode 100644 index 0000000..d36538c --- /dev/null +++ b/apps/waterfall-wiki/src/components/WorkspaceShell.tsx @@ -0,0 +1,306 @@ +import Head from 'next/head'; +import {useEffect, useRef, useState, type ReactNode} from '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 './Brand'; +import {ThemeToggle} from './ThemeToggle'; +import {parseCanvasDocument, type CanvasNode} from '@/lib/canvas-document'; +import s from '@/styles/workspace.module.css'; + +const tools = [ + {label: '无限画布', detail: '收集灵感,连接文字与参考素材', href: '/infinite_canvas', icon: Workflow}, + {label: '图形设计', detail: '编辑文字、图像与版式', href: '/graphic', icon: Palette}, + {label: '知识与教程', detail: '查找指南和创作方法', href: '/documents', icon: BookOpen}, + {label: 'Agent 接入', detail: '了解如何连接创作工具与 API', href: '/studio/api', icon: Bot}, +]; +const purposes = [ + {label: '工作与团队', detail: '整理项目和工作资料。', icon: LayoutGrid}, + {label: '个人创作', detail: '收集灵感,让想法成形。', icon: House}, + {label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen}, +]; +type Space = {name: string; purpose: string}; +export type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string}; +const personal = {name: '个人空间', purpose: ''}; + +type WorkspaceShellProps = { + editor?: (subset: Subcanvas | undefined) => ReactNode; + liveNodes?: CanvasNode[]; +}; + +export function WorkspaceShell({editor, liveNodes}: WorkspaceShellProps) { + const [email, setEmail] = useState(''); + const [state, setState] = useState('loading'); + const [sidebar, setSidebar] = useState(true); + const [spaces, setSpaces] = useState([]); + const [active, setActive] = useState(personal); + const [query, setQuery] = useState(''); + const [step, setStep] = useState<'purpose' | 'name'>('purpose'); + const [purpose, setPurpose] = useState(''); + const [name, setName] = useState(''); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [savedNodes, setNodes] = useState([]); + const nodes = liveNodes ?? savedNodes; + const [subcanvases, setSubcanvases] = useState([]); + const [selectedCanvas, setSelectedCanvas] = useState(editor ? '' : 'history'); + const [draftEditedAt, setDraftEditedAt] = 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); + + useEffect(() => { + setSidebar(window.matchMedia('(min-width: 761px)').matches); + const request = new AbortController(); + fetch('/api/account/session', {signal: request.signal}).then(async response => { + if (!response.ok) throw Error(); + const data = await response.json(); + if (!data.user) { + if (editor) setState('ready'); + else window.location.replace('/account?next=/workspace'); + return; + } + setEmail(data.user.email); + try { + const saved = JSON.parse(localStorage.getItem('studio.workspace:' + data.user.email) || 'null'); + if (Array.isArray(saved?.spaces)) { + setSpaces(saved.spaces); + setActive(saved.spaces.find((space: Space) => space.name === saved.active) || personal); + } + } catch {} + setState('ready'); + }).catch(() => { + if (!request.signal.aborted) setState('error'); + }); + return () => request.abort(); + }, []); + + useEffect(() => { + if (!notice) return; + const timeout = window.setTimeout(() => setNotice(''), 4500); + return () => window.clearTimeout(timeout); + }, [notice]); + + function selectSpace(space: Space, next = spaces) { + localStorage.setItem('studio.workspace:' + email, JSON.stringify({spaces: next, active: space.name})); + setSpaces(next); + setActive(space); + switcher.current?.removeAttribute('open'); + } + + const subsetKey = 'studio.subcanvases:' + email + ':' + active.name; + useEffect(() => { + if (!email) return; + setSelectedCanvas(editor ? '' : 'history'); + setQuery(''); + try { + const draft = localStorage.getItem('studio.infinite-canvas.v1'); + setDraftEditedAt(localStorage.getItem('studio.infinite-canvas.v1.editedAt') || undefined); + setNodes(draft ? parseCanvasDocument(draft).cards : []); + } catch { + setNodes([]); + } + try { + const saved = JSON.parse(localStorage.getItem(subsetKey) || '[]'); + const list: Subcanvas[] = Array.isArray(saved) ? saved : []; + setSubcanvases(list); + if (editor) { + const id = new URLSearchParams(window.location.search).get('subset'); + if (list.some(item => item.id === id)) setSelectedCanvas(id!); + } + } 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, editedAt: new Date().toISOString()}; + 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(''); + setName(''); + setError(''); + dialog.current?.showModal(); + } + + function createSpace(event: React.FormEvent) { + event.preventDefault(); + const clean = name.trim(); + if (!clean) return setError('请填写 Workspace 名称。'); + if ([personal, ...spaces].some(space => space.name === clean)) return setError('已有同名空间。'); + const space = {name: clean, purpose}; + try { + selectSpace(space, [...spaces, space]); + dialog.current?.close(); + setNotice('已创建并切换到「' + clean + '」'); + } catch { + setError('浏览器无法保存此空间,请检查存储权限后重试。'); + } + } + + 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 = '无限画布'; + const showHistory = selectedCanvas === 'history'; + const dates = new Intl.DateTimeFormat('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'}); + const times = new Intl.DateTimeFormat('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false}); + const entries = [ + ...(nodes.length || draftEditedAt ? [{id: '', name: canvasName, editedAt: draftEditedAt, summary: nodes[0]?.text || nodes[0]?.title || '本机草稿'}] : []), + ...subcanvases.map(item => ({...item, summary: nodes.find(node => item.nodes.includes(node.id))?.text || '子画布'})), + ].filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase())); + entries.sort((a, b) => (b.editedAt ?? '').localeCompare(a.editedAt ?? '')); + const history = new Map(); + for (const entry of entries) { + const day = entry.editedAt && Number.isFinite(Date.parse(entry.editedAt)) ? dates.format(new Date(entry.editedAt)) : '编辑时间未记录'; + const group = history.get(day) ?? []; + group.push(entry); + history.set(day, group); + } + return
+ {!editor && 工作台 · 啡跃} + + {sidebar && } +
+ {editor ? <> + + {editor(subset)} + : <> +
+
{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}
+ +
+ {state === 'loading' ?

正在打开工作台…

: state === 'error' ?

暂时无法读取账户。 重新登录

: + showHistory ?
+

编辑历史

按最近编辑日期排列

打开画布
+ {Array.from(history, ([day, items]) =>
+

{day} · {items.length}

+
    {items.map(item =>
  • )}
+
)} + {!entries.length &&

{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}

} +
:
+
+ +

{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 ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}

} +
+ +
} + } +
+ +

{subset ? '编辑子画布' : '新建子画布'}

+
+

从无限画布选择节点。原画布会保留这些节点。

+ + setSubsetName(event.target.value)}/> +
包含的节点{nodes.map(node => )}
+ {error &&

{error}

} + +
+
+ +
+ {step === 'name' && } +

{step === 'purpose' ? '这个空间用来做什么?' : '给你的 Workspace 命名'}

+ +
+ {step === 'purpose' ? <> +

选择一个起点。空间设置仅保存在此浏览器。

+
{purposes.map(({label, detail, icon: Icon}) => )}
+ + :
+

{purpose} · 一个空间,装下你的想法与作品。

+
{Array.from(name.trim())[0] || '空'}空间图标
+ + {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/> + {error && } + +
} +
+ {notice &&
{notice}
} +
; +} diff --git a/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx b/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx index e60d5a0..4994ba8 100644 --- a/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx +++ b/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx @@ -1,5 +1,6 @@ import { ThemeToggle } from "../../components/ThemeToggle"; import { Brand } from "../../studio/components/Brand"; +import {WorkspaceShell, type Subcanvas} from '@/components/WorkspaceShell'; import Head from 'next/head'; import {useEffect,useRef,useState} from 'react'; import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react'; @@ -12,10 +13,20 @@ const example:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意 const initial:Doc={version:1,cards:[],links:[]}; const key='studio.infinite-canvas.v1'; export default function InfiniteCanvas(){ + const [nodes, setNodes] = useState(); + return }/>; +} + +function CanvasEditor({subset,onNodesChange}:{subset?:Subcanvas;onNodesChange:(nodes:Card[])=>void}){ + const [doc,setDoc]=useState(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState([]),[future,setFuture]=useState([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[editing,setEditing]=useState(''),[refMenu,setRefMenu]=useState(''),[taskFeedback,setTaskFeedback]=useState(''),[composerTarget,setComposerTarget]=useState(''),[pickerRequest,setPickerRequest]=useState(0); const file=useRef(null),json=useRef(null),viewport=useRef(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null); const pickerSequence=useRef(0),agent=useRef(null); - const active=doc.cards.find(c=>c.id===selected); + const visibleCards=subset?doc.cards.filter(card=>subset.nodes.includes(card.id)):doc.cards; + const visibleIds=new Set(visibleCards.map(card=>card.id)); + const active=visibleCards.find(c=>c.id===selected); + useEffect(()=>{onNodesChange(doc.cards)},[doc.cards,onNodesChange]); + useEffect(()=>{select('');setEditing('');setMenu(null);setRefMenu('');setComposerTarget('')},[subset?.id]); useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]); useEffect(()=>{if(!ready)return;try{const serialized=JSON.stringify(doc);const previous=localStorage.getItem(key);if(previous!==serialized){localStorage.setItem(key,serialized);if(previous||doc.cards.length)localStorage.setItem(key+'.editedAt',new Date().toISOString());}setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]); useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]); @@ -25,12 +36,12 @@ export default function InfiniteCanvas(){ function scaleAt(next:number,cx?:number,cy?:number){const el=viewport.current;if(!el)return;const r=el.getBoundingClientRect(),x=cx===undefined?r.width/2:cx-r.left,y=cy===undefined?r.height/2:cy-r.top;const z=Math.max(.2,Math.min(2,next));setPan({x:x-(x-pan.x)*z/zoom,y:y-(y-pan.y)*z/zoom});setZoom(z)} function duplicate(){if(!active||doc.cards.length>=80)return;const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');commit({...doc,cards:[...doc.cards,{...active,id,title:active.title.slice(0,197)+' 副本',x:active.x+40,y:active.y+40}]});select(id)} function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)} - function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)} + function add(kind:Card['kind'],image?:string){if(subset){setStatus('请在父画布中添加节点');return;}if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)} function change(field:'title'|'text'|'instruction',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))} function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])} function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))} function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))} - function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})} + function fit(){const el=viewport.current;if(!el||!visibleCards.length)return;const minX=Math.min(...visibleCards.map(c=>c.x)),minY=Math.min(...visibleCards.map(c=>c.y));const w=Math.max(...visibleCards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...visibleCards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})} function selectNode(id:string){if(id!==selected)setEditing('');select(id)} function editNode(id:string){setPickerRequest(0);setComposerTarget('');select(id);setEditing(id);setMenu(null);setRefMenu('');setTaskFeedback('');setTool('select')} function patchNode(id:string,patch:Partial){commit({...doc,cards:doc.cards.map(n=>n.id===id?{...n,...patch}:n)})} @@ -43,18 +54,18 @@ export default function InfiniteCanvas(){ function connect(from:string,to:string){try{const next=connectNodes(doc,from,to);if(next!==doc)commit(next);setTaskFeedback('')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法引用节点')}} function exportTask(id:string){try{const task=agentTask(doc,id),url=URL.createObjectURL(new Blob([JSON.stringify(task,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`canvas-task-${id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setTaskFeedback('Agent 任务已导出')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法导出任务')}} function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)} - return
无限画布 · 啡跃 Endoleap + return
无限画布 · 啡跃 Endoleap
工作空间 未命名的灵感宇宙本机草稿
{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/>{email||'登录账户'}
-
{!doc.cards.length&&
YOUR NEXT IDEA STARTS HERE

给想法,一个无限的空间。

双击画布添加节点,或选择一个起点。

连接你的 coding agent · 接入文档 ↗
}
YOUR IDEAS, CONNECTED

无限画布 / Infinite canvas

从一个灵感,连接所有可能。

+
{subset &&
{subset.name} · {visibleCards.length} 个节点
}{!subset&&!doc.cards.length&&
YOUR NEXT IDEA STARTS HERE

给想法,一个无限的空间。

双击画布添加节点,或选择一个起点。

连接你的 coding agent · 接入文档 ↗
}
YOUR IDEAS, CONNECTED

无限画布 / Infinite canvas

从一个灵感,连接所有可能。

{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}}> -
- {doc.cards.map(c=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)} +
+ {visibleCards.map(c=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)}
● {status}
{Math.round(zoom*100)}%
{menu&&
{refMenu?'引用该节点生成':active?active.title:'添加到画布'}
{refMenu?<>{(['text','image','script'] as const).map(kind=>)}:active?<>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=>)}:<>}
}
{active&&editing===active.id&&(active.kind!=='text'||composerTarget===active.id)&&change('instruction',value,active.id)} onRename={value=>change('title',value,active.id)} onBeginEdit={beginEdit} onUpload={()=>file.current?.click()} onAddReference={id=>connect(id,active.id)} onRemoveReference={id=>commit({...doc,links:doc.links.filter(([from,to])=>from!==id||to!==active.id)})} onQueue={()=>void agent.current?.queue(active.id)} onExport={()=>exportTask(active.id)} onClose={()=>setEditing('')} feedback={taskFeedback}/>} {const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/> {const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=parseCanvasDocument(await f.text());commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/> -
; + ; } diff --git a/apps/waterfall-wiki/src/pages/workspace.tsx b/apps/waterfall-wiki/src/pages/workspace.tsx index 572400d..e26525f 100644 --- a/apps/waterfall-wiki/src/pages/workspace.tsx +++ b/apps/waterfall-wiki/src/pages/workspace.tsx @@ -1,289 +1,5 @@ -import Head from 'next/head'; -import {useEffect, useRef, useState} from '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 = [ - {label: '无限画布', detail: '收集灵感,连接文字与参考素材', href: '/infinite_canvas', icon: Workflow}, - {label: '图形设计', detail: '编辑文字、图像与版式', href: '/graphic', icon: Palette}, - {label: '知识与教程', detail: '查找指南和创作方法', href: '/documents', icon: BookOpen}, - {label: 'Agent 接入', detail: '了解如何连接创作工具与 API', href: '/studio/api', icon: Bot}, -]; -const purposes = [ - {label: '工作与团队', detail: '整理项目和工作资料。', icon: LayoutGrid}, - {label: '个人创作', detail: '收集灵感,让想法成形。', icon: House}, - {label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen}, -]; -type Space = {name: string; purpose: string}; -type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string}; -const personal = {name: '个人空间', purpose: ''}; +import {WorkspaceShell} from '../components/WorkspaceShell'; export default function Workspace() { - const [email, setEmail] = useState(''); - const [state, setState] = useState('loading'); - const [sidebar, setSidebar] = useState(true); - const [spaces, setSpaces] = useState([]); - const [active, setActive] = useState(personal); - const [query, setQuery] = useState(''); - const [step, setStep] = useState<'purpose' | 'name'>('purpose'); - const [purpose, setPurpose] = useState(''); - const [name, setName] = useState(''); - const [error, setError] = useState(''); - const [notice, setNotice] = useState(''); - const [nodes, setNodes] = useState([]); - const [subcanvases, setSubcanvases] = useState([]); - const [selectedCanvas, setSelectedCanvas] = useState('history'); - const [draftEditedAt, setDraftEditedAt] = 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); - - useEffect(() => { - setSidebar(window.matchMedia('(min-width: 761px)').matches); - const request = new AbortController(); - fetch('/api/account/session', {signal: request.signal}).then(async response => { - if (!response.ok) throw Error(); - const data = await response.json(); - if (!data.user) { - window.location.replace('/account?next=/workspace'); - return; - } - setEmail(data.user.email); - try { - const saved = JSON.parse(localStorage.getItem('studio.workspace:' + data.user.email) || 'null'); - if (Array.isArray(saved?.spaces)) { - setSpaces(saved.spaces); - setActive(saved.spaces.find((space: Space) => space.name === saved.active) || personal); - } - } catch {} - setState('ready'); - }).catch(() => { - if (!request.signal.aborted) setState('error'); - }); - return () => request.abort(); - }, []); - - useEffect(() => { - if (!notice) return; - const timeout = window.setTimeout(() => setNotice(''), 4500); - return () => window.clearTimeout(timeout); - }, [notice]); - - function selectSpace(space: Space, next = spaces) { - localStorage.setItem('studio.workspace:' + email, JSON.stringify({spaces: next, active: space.name})); - setSpaces(next); - setActive(space); - switcher.current?.removeAttribute('open'); - } - - const subsetKey = 'studio.subcanvases:' + email + ':' + active.name; - useEffect(() => { - if (!email) return; - setSelectedCanvas('history'); - setQuery(''); - try { - const draft = localStorage.getItem('studio.infinite-canvas.v1'); - setDraftEditedAt(localStorage.getItem('studio.infinite-canvas.v1.editedAt') || undefined); - 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, editedAt: new Date().toISOString()}; - 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(''); - setName(''); - setError(''); - dialog.current?.showModal(); - } - - function createSpace(event: React.FormEvent) { - event.preventDefault(); - const clean = name.trim(); - if (!clean) return setError('请填写 Workspace 名称。'); - if ([personal, ...spaces].some(space => space.name === clean)) return setError('已有同名空间。'); - const space = {name: clean, purpose}; - try { - selectSpace(space, [...spaces, space]); - dialog.current?.close(); - setNotice('已创建并切换到「' + clean + '」'); - } catch { - setError('浏览器无法保存此空间,请检查存储权限后重试。'); - } - } - - 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 = '无限画布'; - const showHistory = selectedCanvas === 'history'; - const dates = new Intl.DateTimeFormat('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'}); - const times = new Intl.DateTimeFormat('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false}); - const entries = [ - ...(nodes.length || draftEditedAt ? [{id: '', name: canvasName, editedAt: draftEditedAt, summary: nodes[0]?.text || nodes[0]?.title || '本机草稿'}] : []), - ...subcanvases.map(item => ({...item, summary: nodes.find(node => item.nodes.includes(node.id))?.text || '子画布'})), - ].filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase())); - entries.sort((a, b) => (b.editedAt ?? '').localeCompare(a.editedAt ?? '')); - const history = new Map(); - for (const entry of entries) { - const day = entry.editedAt && Number.isFinite(Date.parse(entry.editedAt)) ? dates.format(new Date(entry.editedAt)) : '编辑时间未记录'; - const group = history.get(day) ?? []; - group.push(entry); - history.set(day, group); - } - return
- 工作台 · 啡跃 - - {sidebar && } -
-
-
{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}
- -
- {state === 'loading' ?

正在打开工作台…

: state === 'error' ?

暂时无法读取账户。 重新登录

: - showHistory ?
-

编辑历史

按最近编辑日期排列

打开画布
- {Array.from(history, ([day, items]) =>
-

{day} · {items.length}

-
    {items.map(item =>
  • )}
-
)} - {!entries.length &&

{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}

} -
:
-
- -

{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 ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}

} -
- -
} -
- -

{subset ? '编辑子画布' : '新建子画布'}

-
-

从无限画布选择节点。原画布会保留这些节点。

- - setSubsetName(event.target.value)}/> -
包含的节点{nodes.map(node => )}
- {error &&

{error}

} - -
-
- -
- {step === 'name' && } -

{step === 'purpose' ? '这个空间用来做什么?' : '给你的 Workspace 命名'}

- -
- {step === 'purpose' ? <> -

选择一个起点。空间设置仅保存在此浏览器。

-
{purposes.map(({label, detail, icon: Icon}) => )}
- - :
-

{purpose} · 一个空间,装下你的想法与作品。

-
{Array.from(name.trim())[0] || '空'}空间图标
- - {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/> - {error && } - -
} -
- {notice &&
{notice}
} -
; + return ; } diff --git a/apps/waterfall-wiki/src/studio/styles/infinite-canvas.module.css b/apps/waterfall-wiki/src/studio/styles/infinite-canvas.module.css index b9759b4..ae2465f 100644 --- a/apps/waterfall-wiki/src/studio/styles/infinite-canvas.module.css +++ b/apps/waterfall-wiki/src/studio/styles/infinite-canvas.module.css @@ -20,3 +20,5 @@ .header .export:hover{background:var(--primary-hover)} .wires path{stroke:var(--connector)} .brand{display:inline-flex;align-items:center;letter-spacing:normal} +.header { padding-left: 39px; } +.subsetLabel { position: absolute; top: 76px; left: 16px; z-index: 4; max-width: calc(100% - 32px); padding: 6px 10px; border-radius: 6px; background: var(--surface); color: var(--muted-foreground); overflow-wrap: anywhere; pointer-events: none; } diff --git a/apps/waterfall-wiki/src/styles/workspace.module.css b/apps/waterfall-wiki/src/styles/workspace.module.css index 0a398f2..a715eb1 100644 --- a/apps/waterfall-wiki/src/styles/workspace.module.css +++ b/apps/waterfall-wiki/src/styles/workspace.module.css @@ -102,3 +102,10 @@ .historyItem small { margin-top: 4px; } .history time { font-variant-numeric: tabular-nums; } .historyEmpty { padding: 18px 0; line-height: 1.65; } +.editorMain { position: relative; flex: 1; min-width: 0; height: 100dvh; } +.editorToggle { position: absolute; z-index: 6; top: 22px; left: 20px; display: grid; place-items: center; width: 32px; height: 34px; border-radius: 6px; color: var(--muted-foreground); } +.editorToggle:hover { background: var(--row-hover); } +.openSubset { display: inline-flex; align-items: center; gap: 6px; margin-inline-start: 12px; } +@media(max-width:760px) { + .editorToggle { top: 16px; left: 12px; } +}