Keep the workspace canvas tree in the editor
This commit is contained in:
@@ -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<Space[]>([]);
|
||||
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<CanvasNode[]>([]);
|
||||
const nodes = liveNodes ?? savedNodes;
|
||||
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
|
||||
const [selectedCanvas, setSelectedCanvas] = useState(editor ? '' : 'history');
|
||||
const [draftEditedAt, setDraftEditedAt] = useState<string>();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [subsetName, setSubsetName] = useState('');
|
||||
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
|
||||
const subsetDialog = useRef<HTMLDialogElement>(null);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const switcher = useRef<HTMLDetailsElement>(null);
|
||||
const search = useRef<HTMLInputElement>(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<string, typeof entries>();
|
||||
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 <div className={s.app}>
|
||||
{!editor && <Head><title>工作台 · 啡跃</title></Head>}
|
||||
<nav className={s.rail} aria-label="全局导航">
|
||||
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand markOnly/></a>
|
||||
<button type="button" aria-label="切换工作台导航" title="切换工作台导航" aria-current="page" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><LayoutGrid size={18}/></button>
|
||||
<a href="/infinite_canvas" aria-label="无限画布" title="无限画布"><Workflow size={18}/></a>
|
||||
<button type="button" aria-label="搜索画布" title="搜索画布" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
|
||||
<a href="/graphic" aria-label="图形设计" title="图形设计"><Palette size={18}/></a>
|
||||
<a href="/studio/api" aria-label="Agent 接入" title="Agent 接入"><Bot size={18}/></a>
|
||||
<a className={s.settings} href="/account" aria-label="账户设置" title="账户设置"><Settings size={18}/></a>
|
||||
</nav>
|
||||
{sidebar && <aside className={s.panel} id="workspace-navigation">
|
||||
<div className={s.identity}>
|
||||
<details ref={switcher} className={s.switcher}>
|
||||
<summary><span className={s.avatar}>{Array.from(active.name)[0]}</span><strong>{active.name}</strong><ChevronDown size={14}/></summary>
|
||||
{state === 'ready' && <div className={s.menu}>
|
||||
<div className={s.menuIdentity}><span className={s.avatar}>{Array.from(active.name)[0]}</span><div><strong>{active.name}</strong><small>当前空间 · 保存在此浏览器</small></div></div>
|
||||
{email && <div className={s.menuSection}><small>{email}</small>
|
||||
{[personal, ...spaces].map(space => <button type="button" key={space.name} aria-pressed={space.name === active.name} onClick={() => {
|
||||
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
|
||||
}}><span className={s.avatar}>{Array.from(space.name)[0]}</span><span>{space.name}</span>{space.name === active.name && <Check size={16}/>}</button>)}
|
||||
<button className={s.menuAdd} type="button" onClick={() => {switcher.current?.removeAttribute('open'); openDialog();}}><Plus size={16}/>添加 Workspace</button>
|
||||
</div>}
|
||||
<div className={s.menuSection}><a href="/account"><User size={16}/>账户管理</a>{email && <button type="button" onClick={() => void signOut()}><LogOut size={16}/>退出登录</button>}</div>
|
||||
</div>}
|
||||
</details>
|
||||
<a className={s.avatar} href="/account" aria-label="账户设置" title={email || '账户设置'}>{email ? email.charAt(0).toUpperCase() : <Settings size={15}/>}</a>
|
||||
</div>
|
||||
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!email || !nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><Plus size={18}/></button></div>
|
||||
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索画布…" aria-label="搜索画布名称"/></label>
|
||||
{state === 'ready' && <nav className={s.links} aria-label="画布树">
|
||||
<button className={s.historyLink} type="button" aria-current={showHistory ? 'page' : undefined} onClick={() => editor ? window.location.assign('/workspace') : setSelectedCanvas('history')}>编辑历史</button>
|
||||
<p>本机画布</p>
|
||||
<div className={s.treeRoot}>
|
||||
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
|
||||
<button type="button" aria-current={!subset && !showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
|
||||
</div>
|
||||
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
|
||||
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
|
||||
</nav>}
|
||||
<small className={s.local}>空间设置保存在此浏览器</small>
|
||||
</aside>}
|
||||
<main className={editor ? s.editorMain : s.main}>
|
||||
{editor ? <>
|
||||
<button className={s.editorToggle} type="button" aria-label="切换画布树" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button>
|
||||
{editor(subset)}
|
||||
</> : <>
|
||||
<header className={s.top}>
|
||||
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}</span></div>
|
||||
<ThemeToggle/>
|
||||
</header>
|
||||
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
|
||||
showHistory ? <section className={s.history} aria-labelledby="history-heading">
|
||||
<header><div><h1 id="history-heading">编辑历史</h1><p>按最近编辑日期排列</p></div><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></header>
|
||||
{Array.from(history, ([day, items]) => <section className={s.historyGroup} key={day}>
|
||||
<h2>{day} · {items.length}</h2>
|
||||
<ul>{items.map(item => <li key={item.id}><button type="button" onClick={() => setSelectedCanvas(item.id)}>
|
||||
{item.id ? <LayoutGrid size={18}/> : <Layers size={18}/>}
|
||||
<span className={s.historyItem}><strong>{item.name}</strong><small>{item.summary}</small></span>
|
||||
{item.editedAt && Number.isFinite(Date.parse(item.editedAt)) && <time dateTime={item.editedAt}>{times.format(new Date(item.editedAt))}</time>}
|
||||
<ChevronRight size={16}/>
|
||||
</button></li>)}</ul>
|
||||
</section>)}
|
||||
{!entries.length && <p className={s.historyEmpty}>{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}</p>}
|
||||
</section> : <div className={s.content}>
|
||||
<section className={s.intro}>
|
||||
<Layers size={20}/>
|
||||
<h1>{subset?.name ?? canvasName}</h1>
|
||||
<p>{subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}</p>
|
||||
{subset ? <><button className={s.primary} type="button" onClick={() => openSubsetDialog(subset)}>编辑子画布</button><a className={s.openSubset} href={'/infinite_canvas?subset=' + encodeURIComponent(subset.id)}>在编辑器打开 <ArrowUpRight size={16}/></a></> : <a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a>}
|
||||
</section>
|
||||
<section aria-labelledby="workspace-nodes">
|
||||
<h2 id="workspace-nodes">画布内容</h2>
|
||||
{visibleNodes.length ? <div className={s.tools}>{visibleNodes.map(node => <article className={s.node} key={node.id}><strong>{node.title || '未命名节点'}</strong>{node.image && <img src={node.image} alt=""/>}{node.text && <p>{node.text}</p>}</article>)}</div> : <p>{subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}</p>}
|
||||
</section>
|
||||
<nav className={s.shortcuts} aria-label="其他创作工具">{tools.slice(1).map(({label, href}) => <a key={href} href={href}>{label} <ArrowUpRight size={14}/></a>)}</nav>
|
||||
</div>}
|
||||
</>}
|
||||
</main>
|
||||
<dialog className={s.dialog} ref={subsetDialog} aria-labelledby="subcanvas-title">
|
||||
<div className={s.dialogTop}><h2 id="subcanvas-title">{subset ? '编辑子画布' : '新建子画布'}</h2><button type="button" aria-label="关闭" onClick={() => subsetDialog.current?.close()}><X size={20}/></button></div>
|
||||
<form onSubmit={saveSubset}>
|
||||
<p>从无限画布选择节点。原画布会保留这些节点。</p>
|
||||
<label htmlFor="subcanvas-name">子画布名称</label>
|
||||
<input id="subcanvas-name" value={subsetName} maxLength={40} onChange={event => setSubsetName(event.target.value)}/>
|
||||
<fieldset className={s.members}><legend>包含的节点</legend>{nodes.map(node => <label key={node.id}><input type="checkbox" checked={subsetNodes.includes(node.id)} onChange={event => setSubsetNodes(event.target.checked ? [...subsetNodes, node.id] : subsetNodes.filter(id => id !== node.id))}/>{node.title || '未命名节点'}</label>)}</fieldset>
|
||||
{error && <p className={s.error} role="alert">{error}</p>}
|
||||
<button className={s.primary} type="submit">保存子画布</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
|
||||
<div className={s.dialogTop}>
|
||||
{step === 'name' && <button type="button" aria-label="返回用途选择" onClick={() => setStep('purpose')}><ArrowLeft size={20}/></button>}
|
||||
<h2 id="workspace-dialog-title">{step === 'purpose' ? '这个空间用来做什么?' : '给你的 Workspace 命名'}</h2>
|
||||
<button type="button" aria-label="关闭" onClick={() => dialog.current?.close()}><X size={20}/></button>
|
||||
</div>
|
||||
{step === 'purpose' ? <>
|
||||
<p>选择一个起点。空间设置仅保存在此浏览器。</p>
|
||||
<div className={s.options}>{purposes.map(({label, detail, icon: Icon}) => <button key={label} type="button" aria-pressed={purpose === label} onClick={() => setPurpose(label)}><Icon size={23}/><span><strong>{label}</strong><small>{detail}</small></span></button>)}</div>
|
||||
<button className={s.primary} type="button" disabled={!purpose} onClick={() => setStep('name')}>继续 <ArrowUpRight size={16}/></button>
|
||||
</> : <form onSubmit={createSpace}>
|
||||
<p>{purpose} · 一个空间,装下你的想法与作品。</p>
|
||||
<div className={s.iconPreview}><span className={s.avatar}>{Array.from(name.trim())[0] || '空'}</span>空间图标</div>
|
||||
<label htmlFor="workspace-name">Workspace 名称</label>
|
||||
<input id="workspace-name" autoFocus value={name} maxLength={40} onChange={e => {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/>
|
||||
{error && <p id="workspace-error" role="alert" className={s.error}>{error}</p>}
|
||||
<button className={s.primary} type="submit">创建 Workspace <ArrowUpRight size={16}/></button>
|
||||
</form>}
|
||||
</dialog>
|
||||
{notice && <div role="status" className={s.notice}>{notice}</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -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<Card[]>();
|
||||
return <WorkspaceShell liveNodes={nodes} editor={subset => <CanvasEditor subset={subset} onNodesChange={setNodes}/>}/>;
|
||||
}
|
||||
|
||||
function CanvasEditor({subset,onNodesChange}:{subset?:Subcanvas;onNodesChange:(nodes:Card[])=>void}){
|
||||
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[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<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(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<CanvasAgentHandle>(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<Card>){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 <main className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
return <div className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回工作台"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}><Brand compact/></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><ThemeToggle/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
|
||||
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
|
||||
<section className={s.canvasArea}>{subset && <div className={s.subsetLabel}>{subset.name} · {visibleCards.length} 个节点</div>}{!subset&&!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
|
||||
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{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}}>
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
|
||||
{doc.cards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>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}/>)}
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.filter(([a,b])=>visibleIds.has(a)&&visibleIds.has(b)).map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
|
||||
{visibleCards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>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}/>)}
|
||||
|
||||
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{refMenu?'引用该节点生成':active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{refMenu?<>{(['text','image','script'] as const).map(kind=><button key={kind} onClick={()=>derive(refMenu,kind,kind==='script'?'script':'generate')}>{kind==='text'?'文本':kind==='image'?'图片':'脚本'}</button>)}</>:active?<><button onClick={()=>{editNode(active.id);setComposerTarget(active.id)}}>创作要求</button><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit(removeCanvasNode(doc,selected));select('');setEditing('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{connect(selected,link);setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('script');setMenu(null)}}>添加脚本</button></>}</div>}</div>
|
||||
{active&&editing===active.id&&(active.kind!=='text'||composerTarget===active.id)&&<CanvasComposer pickerRequest={pickerRequest} key={active.id} node={active} nodes={doc.cards} references={referencedNodes(doc,active.id)} onChange={value=>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}/>}
|
||||
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{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)}}/>
|
||||
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{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('无法导入:项目格式错误或文件过大')}}}/>
|
||||
</main>;
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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<Space[]>([]);
|
||||
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<CanvasNode[]>([]);
|
||||
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
|
||||
const [selectedCanvas, setSelectedCanvas] = useState('history');
|
||||
const [draftEditedAt, setDraftEditedAt] = useState<string>();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [subsetName, setSubsetName] = useState('');
|
||||
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
|
||||
const subsetDialog = useRef<HTMLDialogElement>(null);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const switcher = useRef<HTMLDetailsElement>(null);
|
||||
const search = useRef<HTMLInputElement>(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<string, typeof entries>();
|
||||
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 <div className={s.app}>
|
||||
<Head><title>工作台 · 啡跃</title></Head>
|
||||
<nav className={s.rail} aria-label="全局导航">
|
||||
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand markOnly/></a>
|
||||
<button type="button" aria-label="切换工作台导航" title="切换工作台导航" aria-current="page" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><LayoutGrid size={18}/></button>
|
||||
<a href="/infinite_canvas" aria-label="无限画布" title="无限画布"><Workflow size={18}/></a>
|
||||
<button type="button" aria-label="搜索画布" title="搜索画布" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
|
||||
<a href="/graphic" aria-label="图形设计" title="图形设计"><Palette size={18}/></a>
|
||||
<a href="/studio/api" aria-label="Agent 接入" title="Agent 接入"><Bot size={18}/></a>
|
||||
<a className={s.settings} href="/account" aria-label="账户设置" title="账户设置"><Settings size={18}/></a>
|
||||
</nav>
|
||||
{sidebar && <aside className={s.panel} id="workspace-navigation">
|
||||
<div className={s.identity}>
|
||||
<details ref={switcher} className={s.switcher}>
|
||||
<summary><span className={s.avatar}>{Array.from(active.name)[0]}</span><strong>{active.name}</strong><ChevronDown size={14}/></summary>
|
||||
{state === 'ready' && <div className={s.menu}>
|
||||
<div className={s.menuIdentity}><span className={s.avatar}>{Array.from(active.name)[0]}</span><div><strong>{active.name}</strong><small>当前空间 · 保存在此浏览器</small></div></div>
|
||||
<div className={s.menuSection}><small>{email}</small>
|
||||
{[personal, ...spaces].map(space => <button type="button" key={space.name} aria-pressed={space.name === active.name} onClick={() => {
|
||||
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
|
||||
}}><span className={s.avatar}>{Array.from(space.name)[0]}</span><span>{space.name}</span>{space.name === active.name && <Check size={16}/>}</button>)}
|
||||
<button className={s.menuAdd} type="button" onClick={() => {switcher.current?.removeAttribute('open'); openDialog();}}><Plus size={16}/>添加 Workspace</button>
|
||||
</div>
|
||||
<div className={s.menuSection}><a href="/account"><User size={16}/>账户管理</a><button type="button" onClick={() => void signOut()}><LogOut size={16}/>退出登录</button></div>
|
||||
</div>}
|
||||
</details>
|
||||
<a className={s.avatar} href="/account" aria-label="账户设置" title={email || '账户设置'}>{email ? email.charAt(0).toUpperCase() : <Settings size={15}/>}</a>
|
||||
</div>
|
||||
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><Plus size={18}/></button></div>
|
||||
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索画布…" aria-label="搜索画布名称"/></label>
|
||||
{state === 'ready' && <nav className={s.links} aria-label="画布树">
|
||||
<button className={s.historyLink} type="button" aria-current={showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('history')}>编辑历史</button>
|
||||
<p>本机画布</p>
|
||||
<div className={s.treeRoot}>
|
||||
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
|
||||
<button type="button" aria-current={!subset && !showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
|
||||
</div>
|
||||
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
|
||||
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
|
||||
</nav>}
|
||||
<small className={s.local}>空间设置保存在此浏览器</small>
|
||||
</aside>}
|
||||
<main className={s.main}>
|
||||
<header className={s.top}>
|
||||
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}</span></div>
|
||||
<ThemeToggle/>
|
||||
</header>
|
||||
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
|
||||
showHistory ? <section className={s.history} aria-labelledby="history-heading">
|
||||
<header><div><h1 id="history-heading">编辑历史</h1><p>按最近编辑日期排列</p></div><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></header>
|
||||
{Array.from(history, ([day, items]) => <section className={s.historyGroup} key={day}>
|
||||
<h2>{day} · {items.length}</h2>
|
||||
<ul>{items.map(item => <li key={item.id}><button type="button" onClick={() => setSelectedCanvas(item.id)}>
|
||||
{item.id ? <LayoutGrid size={18}/> : <Layers size={18}/>}
|
||||
<span className={s.historyItem}><strong>{item.name}</strong><small>{item.summary}</small></span>
|
||||
{item.editedAt && Number.isFinite(Date.parse(item.editedAt)) && <time dateTime={item.editedAt}>{times.format(new Date(item.editedAt))}</time>}
|
||||
<ChevronRight size={16}/>
|
||||
</button></li>)}</ul>
|
||||
</section>)}
|
||||
{!entries.length && <p className={s.historyEmpty}>{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}</p>}
|
||||
</section> : <div className={s.content}>
|
||||
<section className={s.intro}>
|
||||
<Layers size={20}/>
|
||||
<h1>{subset?.name ?? canvasName}</h1>
|
||||
<p>{subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}</p>
|
||||
{subset ? <button className={s.primary} type="button" onClick={() => openSubsetDialog(subset)}>编辑子画布</button> : <a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a>}
|
||||
</section>
|
||||
<section aria-labelledby="workspace-nodes">
|
||||
<h2 id="workspace-nodes">画布内容</h2>
|
||||
{visibleNodes.length ? <div className={s.tools}>{visibleNodes.map(node => <article className={s.node} key={node.id}><strong>{node.title || '未命名节点'}</strong>{node.image && <img src={node.image} alt=""/>}{node.text && <p>{node.text}</p>}</article>)}</div> : <p>{subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}</p>}
|
||||
</section>
|
||||
<nav className={s.shortcuts} aria-label="其他创作工具">{tools.slice(1).map(({label, href}) => <a key={href} href={href}>{label} <ArrowUpRight size={14}/></a>)}</nav>
|
||||
</div>}
|
||||
</main>
|
||||
<dialog className={s.dialog} ref={subsetDialog} aria-labelledby="subcanvas-title">
|
||||
<div className={s.dialogTop}><h2 id="subcanvas-title">{subset ? '编辑子画布' : '新建子画布'}</h2><button type="button" aria-label="关闭" onClick={() => subsetDialog.current?.close()}><X size={20}/></button></div>
|
||||
<form onSubmit={saveSubset}>
|
||||
<p>从无限画布选择节点。原画布会保留这些节点。</p>
|
||||
<label htmlFor="subcanvas-name">子画布名称</label>
|
||||
<input id="subcanvas-name" value={subsetName} maxLength={40} onChange={event => setSubsetName(event.target.value)}/>
|
||||
<fieldset className={s.members}><legend>包含的节点</legend>{nodes.map(node => <label key={node.id}><input type="checkbox" checked={subsetNodes.includes(node.id)} onChange={event => setSubsetNodes(event.target.checked ? [...subsetNodes, node.id] : subsetNodes.filter(id => id !== node.id))}/>{node.title || '未命名节点'}</label>)}</fieldset>
|
||||
{error && <p className={s.error} role="alert">{error}</p>}
|
||||
<button className={s.primary} type="submit">保存子画布</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
|
||||
<div className={s.dialogTop}>
|
||||
{step === 'name' && <button type="button" aria-label="返回用途选择" onClick={() => setStep('purpose')}><ArrowLeft size={20}/></button>}
|
||||
<h2 id="workspace-dialog-title">{step === 'purpose' ? '这个空间用来做什么?' : '给你的 Workspace 命名'}</h2>
|
||||
<button type="button" aria-label="关闭" onClick={() => dialog.current?.close()}><X size={20}/></button>
|
||||
</div>
|
||||
{step === 'purpose' ? <>
|
||||
<p>选择一个起点。空间设置仅保存在此浏览器。</p>
|
||||
<div className={s.options}>{purposes.map(({label, detail, icon: Icon}) => <button key={label} type="button" aria-pressed={purpose === label} onClick={() => setPurpose(label)}><Icon size={23}/><span><strong>{label}</strong><small>{detail}</small></span></button>)}</div>
|
||||
<button className={s.primary} type="button" disabled={!purpose} onClick={() => setStep('name')}>继续 <ArrowUpRight size={16}/></button>
|
||||
</> : <form onSubmit={createSpace}>
|
||||
<p>{purpose} · 一个空间,装下你的想法与作品。</p>
|
||||
<div className={s.iconPreview}><span className={s.avatar}>{Array.from(name.trim())[0] || '空'}</span>空间图标</div>
|
||||
<label htmlFor="workspace-name">Workspace 名称</label>
|
||||
<input id="workspace-name" autoFocus value={name} maxLength={40} onChange={e => {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/>
|
||||
{error && <p id="workspace-error" role="alert" className={s.error}>{error}</p>}
|
||||
<button className={s.primary} type="submit">创建 Workspace <ArrowUpRight size={16}/></button>
|
||||
</form>}
|
||||
</dialog>
|
||||
{notice && <div role="status" className={s.notice}>{notice}</div>}
|
||||
</div>;
|
||||
return <WorkspaceShell/>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user