diff --git a/apps/waterfall-wiki/src/components/Brand.tsx b/apps/waterfall-wiki/src/components/Brand.tsx index 15d833b..180ae51 100644 --- a/apps/waterfall-wiki/src/components/Brand.tsx +++ b/apps/waterfall-wiki/src/components/Brand.tsx @@ -1,4 +1,4 @@ /** The approved mark is copied from @endoleap/brand during the remote build. */ -export function Brand({ compact = false }: { compact?: boolean }) { - return 啡跃{!compact && Endoleap}; +export function Brand({ compact = false, markOnly = false }: { compact?: boolean; markOnly?: boolean }) { + return {!markOnly && 啡跃{!compact && Endoleap}}; } diff --git a/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx b/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx index cf204ec..7aa05ec 100644 --- a/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx +++ b/apps/waterfall-wiki/src/pages/infinite_canvas/index.tsx @@ -1,4 +1,4 @@ -import { ThemeToggle } from "../../studio/components/ThemeToggle"; +import { ThemeToggle } from "../../components/ThemeToggle"; import { Brand } from "../../studio/components/Brand"; import Head from 'next/head'; import {useEffect,useRef,useState} from 'react'; @@ -44,7 +44,7 @@ export default function InfiniteCanvas(){ 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 -
工作空间 未命名的灵感宇宙本机草稿
{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/>{email||'登录账户'}
+
工作空间 未命名的灵感宇宙本机草稿
{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/>{email||'登录账户'}
{!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}}> 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.map(({label, detail, href, icon: Icon}) => {label}{detail})}
- {!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 ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}

} +
+
}
+ +

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

+
+

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

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

{error}

} + +
+
{step === 'name' && } diff --git a/apps/waterfall-wiki/src/styles/workspace.module.css b/apps/waterfall-wiki/src/styles/workspace.module.css index 831ba3c..de6a048 100644 --- a/apps/waterfall-wiki/src/styles/workspace.module.css +++ b/apps/waterfall-wiki/src/styles/workspace.module.css @@ -5,8 +5,7 @@ .rail > :hover, .links a:hover, .top button:hover, .panelTitle button:hover, .dialogTop button:hover, .menu button:hover { background: var(--row-hover); } .rail [aria-current], .links a[aria-current], .menu button[aria-pressed=true] { background: var(--selection); color: var(--foreground-strong); } .logo { margin-bottom: 14px; } -.logo :global(.endoleap-wordmark) { display: none; } -.logo :global(.endoleap-mark) { width: 26px; height: 26px; } +.logo :global(.endoleap-brand) :global(.endoleap-mark) { width: 26px; height: 26px; } .settings { margin-top: auto; } .panel { width: 248px; padding: 0 12px 14px; display: flex; flex-direction: column; background: var(--surface); } .identity, .panelTitle { display: flex; align-items: center; justify-content: space-between; gap: 8px; } @@ -61,3 +60,26 @@ .dialog .error { color: var(--danger); } .notice { position: fixed; right: 16px; bottom: 16px; z-index: 4; max-width: calc(100vw - 32px); overflow-wrap: anywhere; padding: 14px 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px var(--shadow); } @media (max-width: 760px) { .panel { position: fixed; left: 50px; width: min(248px, calc(100% - 50px)); z-index: 3; box-shadow: 8px 0 20px var(--shadow); } .rail { z-index: 4; } .top { position: sticky; top: 0; z-index: 4; margin-left: 8px; } .app:has(.panel) .top { z-index: 2; } .tools { grid-template-columns: 1fr; } } +.panel { z-index: 3; } +.menu { width: min(286px, calc(100vw - 76px)); padding: 10px; } +.menuIdentity { display: flex; gap: 10px; align-items: center; padding: 4px 4px 10px; } +.menuIdentity small, .menuSection small { display: block; color: var(--muted-foreground); overflow-wrap: anywhere; margin: 4px 0; } +.menuSection { border-top: 1px solid var(--border); padding: 6px 0; } +.menuSection a { display: flex; align-items: center; gap: 9px; padding: 8px 9px; border-radius: 6px; } +.menuSection a:hover, .treeRoot button:hover, .children button:hover { background: var(--row-hover); } +.menuSection button > svg:last-child:not(:first-child) { margin-left: auto; } +.menuSection .menuAdd { justify-content: center; background: var(--surface-muted); margin-top: 4px; } +.treeRoot { display: flex; gap: 2px; } +.treeRoot button, .children button { display: flex; align-items: center; gap: 8px; min-height: 34px; border-radius: 6px; text-align: left; overflow-wrap: anywhere; } +.treeRoot button:last-child, .children button { flex: 1; width: 100%; padding: 5px 8px; } +.treeRoot button[aria-current], .children button[aria-current] { background: var(--selection); } +.children { list-style: none; margin: 2px 0 0 21px; padding: 0 0 0 7px; border-left: 1px solid var(--border); } +.children svg, .treeRoot svg { flex-shrink: 0; } +.node { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); overflow-wrap: anywhere; } +.node p { white-space: pre-wrap; } +.node img { max-height: 200px; object-fit: contain; margin-top: 8px; } +.shortcuts { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; } +.shortcuts a { display: inline-flex; align-items: center; gap: 4px; padding: 6px; } +.members { display: grid; gap: 8px; max-height: 30vh; overflow: auto; margin: 0 0 16px; border: 1px solid var(--border); } +.members label { display: flex; align-items: center; gap: 8px; overflow-wrap: anywhere; } +.members input[type=checkbox] { width: auto; min-height: 0; margin: 0; accent-color: var(--primary); }