Add light and dark canvas themes with floating dock and contextual actions

This commit is contained in:
2026-10-30 00:42:33 +08:00
parent ef66f687bd
commit e2bc4ae6bd
7 changed files with 23 additions and 12 deletions
+11 -10
View File
@@ -1,6 +1,6 @@
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} from 'lucide-react';
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react';
import s from '../../styles/infinite-canvas.module.css';
type Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string;width?:number;height?:number};
type Doc={version:1;cards:Card[];links:[string,string][]};
@@ -8,33 +8,34 @@ const initial:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意
const key='studio.infinite-canvas.v1';
function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&& (c.width===undefined||(Number.isFinite(c.width)&&c.width>=260&&c.width<=1200))&&(c.height===undefined||(Number.isFinite(c.height)&&c.height>=300&&c.height<=1200))&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));}
export default function InfiniteCanvas(){
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(''),[properties,setProperties]=useState(false);
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),[theme,setTheme]=useState<'light'|'dark'>('dark');
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 active=doc.cards.find(c=>c.id===selected);
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){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(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){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{}try{const t=localStorage.getItem(key+'.theme');setTheme(t==='light'?'light':'dark')}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{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.theme',theme)}catch{}},[theme,ready]);
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'?'参考素材':'生成提示词',text:'',x:((rect?.width||700)/2-pan.x)/zoom-160,y:(160-pan.y)/zoom,image}]});select(id)}
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'?'参考素材':'生成提示词',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',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 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>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><button aria-label="节点属性" aria-pressed={properties} onClick={()=>setProperties(v=>!v)}>属性</button><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
return <main className={s.app} data-theme={theme}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><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}><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} 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=>{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 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={()=>{const d=drag.current;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.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} 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);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 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=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} data-node-id={c.id} style={{left:c.x,top:c.y,width:c.width||320,height:c.height||350}} onClick={()=>select(c.id)}><div className={s.cardHead} title="拖动手柄 · 移动工具" aria-label={`移动 ${c.title}`} onPointerDown={e=>{if(tool==='hand'||e.button!==0)return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} onClick={()=>{if(tool==='hand')return;select(c.id);file.current?.click()}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>示例占位 · 点击上传参考图</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><input aria-label={`${c.title} 名称`} value={c.title} maxLength={200} onFocus={beginEdit} onChange={e=>change('title',e.target.value,c.id)}/><textarea aria-label={`${c.title} 内容`} value={c.text} maxLength={10000} placeholder="在工具内直接编辑…" onFocus={beginEdit} onChange={e=>change('text',e.target.value,c.id)}/></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}</footer><span className={s.inputPort}/><span className={s.outputPort}/><button className={s.resize} aria-label={`调整 ${c.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();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}}}>↘</button></article>)}
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} data-node-id={c.id} style={{left:c.x,top:c.y,width:c.width||320,height:c.height||350}} onClick={()=>select(c.id)}><div className={s.cardHead} title="拖动手柄 · 移动工具" aria-label={`移动 ${c.title}`} onPointerDown={e=>{if(tool==='hand'||e.button!==0)return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} onClick={()=>{if(tool==='hand')return;select(c.id);file.current?.click()}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>示例占位 · 点击上传参考图</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><input aria-label={`${c.title} 名称`} value={c.title} maxLength={200} onFocus={beginEdit} onChange={e=>change('title',e.target.value,c.id)}/><textarea aria-label={`${c.title} 内容`} value={c.text} maxLength={10000} placeholder="在工具内直接编辑…" onFocus={beginEdit} onChange={e=>change('text',e.target.value,c.id)}/></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<button aria-label={`${c.title} 操作`} onClick={e=>{e.stopPropagation();select(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}}>•••</button></footer><span className={s.inputPort}/><span className={s.outputPort}/><button className={s.resize} aria-label={`调整 ${c.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();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}}}>↘</button></article>)}
</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>
<aside className={`${s.inspector} ${properties?s.inspectorOpen:s.inspectorClosed}`}><div className={s.panelHeading}><Workflow size={17}/><strong>节点属性</strong><span>{doc.cards.length} 个节点</span></div>{active?<><span className={s.eyebrow}>INSPECTOR</span><h2>{active.kind==='prompt'?'把想法描述清楚':active.kind==='image'?'让参考彼此连接':'记录灵感的起点'}</h2><label>节点名称<input maxLength={200} value={active.title} onFocus={beginEdit} onChange={e=>change('title',e.target.value)}/></label><label>{active.kind==='prompt'?'画面描述':'笔记内容'}<textarea maxLength={10000} value={active.text} onFocus={beginEdit} onChange={e=>change('text',e.target.value)} placeholder="写下你的创作想法…"/></label>{active.kind==='image'&&<button className={s.wide} onClick={()=>file.current?.click()}><ImagePlus size={16}/>选择参考图片</button>}{active.kind==='prompt'&&<div className={s.notice}><Sparkles size={18}/><strong>为下一次创作做好准备</strong><p>提示词可保存在项目中。当前没有连接模型服务,暂不支持 AI 图片或视频生成。</p></div>}<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 className={s.wide} disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}><Link2 size={16}/>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button className={s.connection} 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} <span>断开 ×</span></button>)}<button className={s.wide} onClick={duplicate}>复制节点</button><button className={s.delete} onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('')}}><Trash2 size={15}/>删除节点</button></>:<><h2>给灵感留一点空间</h2><p>选择画布中的节点来编辑,或从左侧添加新内容。</p><button className={s.wide} onClick={()=>add('text')}><Plus size={16}/>添加笔记</button></>}<div className={s.help}>拖动节点标题手柄移动 · 右下角调整尺寸<br/>滚轮平移 · Ctrl / ⌘ + 滚轮缩放<br/>草稿仅存于当前浏览器,请及时导出。</div></aside></div>
{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}>{active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{active?<><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('');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={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[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('prompt');setMenu(null)}}>添加提示词</button></>}</div>}</div>
<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=JSON.parse(await f.text());if(!valid(d))throw Error();commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
</main>;
@@ -4,3 +4,6 @@
.card{overflow:visible}.cardBody{overflow:hidden}.inputPort,.outputPort{position:absolute;top:50%;width:10px;height:10px;margin-top:-5px;border:2px solid #1a1d20;border-radius:50%;background:#d9ef9b;pointer-events:none}.inputPort{left:-5px}.outputPort{right:-5px}.cardHead:active{cursor:grabbing}
/* Full-screen canvas: chrome floats above the world and never reserves layout space. */
.app{height:100dvh;min-height:0;overflow:hidden}.workspace{position:absolute;inset:0;height:100%;display:block}.canvasArea{position:absolute;inset:0;width:100%;height:100%;background:none}.viewport{inset:0;background-color:#16191c;background-image:radial-gradient(circle,#556068 1px,transparent 1px);background-size:24px 24px}.canvasTitle{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.header{position:absolute;z-index:5;top:16px;left:16px;right:auto;height:48px;max-width:calc(100% - 32px);padding:6px 10px;gap:12px;background:#202529eb;border:1px solid #424a51;border-radius:12px;box-shadow:0 8px 24px #0003;backdrop-filter:blur(12px)}.header strong,.header>i,.header .crumb,.header .badge{display:none}.header .brand{font-size:14px}.header .brand span{font-size:18px}.header a[href='/account']{font-size:11px;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.header .export{padding:8px}.rail{position:absolute;z-index:4;left:16px;top:50%;transform:translateY(-50%);width:48px;height:auto;padding:6px;gap:5px;border:1px solid #424a51;border-radius:12px;background:#202529eb;box-shadow:0 8px 24px #0003;backdrop-filter:blur(12px)}.rail button{width:34px;height:34px;padding:8px}.rail>.spacer,.rail>span{display:none}.rail hr{margin:3px}.canvasBottom{z-index:3;bottom:16px;left:16px;right:16px;pointer-events:none;flex-direction:row;align-items:center}.canvasBottom>span{max-width:none;padding:7px 10px;border-radius:7px;background:#202529c9}.controls{pointer-events:auto}.inspector{display:none;position:absolute;z-index:4;top:76px;left:16px;width:280px;max-height:calc(100dvh - 160px);height:auto;border:1px solid #424a51;border-radius:12px;padding:0 18px 20px;box-shadow:0 12px 32px #0005}.inspectorOpen{display:block}.inspectorClosed{display:none}.panelHeading{height:48px;margin-bottom:18px}.inspector h2{font-size:16px;margin-bottom:16px}.inspector .help{margin-top:18px}@media(max-width:700px){.header{top:10px;left:10px;max-width:calc(100% - 20px);gap:8px}.header>a:not(.brand):not(.back){display:none}.header .brand{font-size:12px}.header .export{font-size:11px}.rail{left:10px}.canvasBottom{left:10px;right:10px;bottom:10px;align-items:flex-end;gap:6px}.canvasBottom>span{max-width:110px;font-size:9px}.controls{gap:1px}.controls button{padding:6px}.inspector{left:10px;top:68px;width:min(280px,calc(100vw - 20px))}}
/* Dock and contextual actions leave both sides of the canvas clear. */
.rail{left:50%;top:auto;bottom:18px;transform:translateX(-50%);width:auto;height:58px;flex-direction:row;padding:7px 10px;gap:8px;border-radius:19px}.rail hr{width:1px;height:27px;border:0;background:#465059}.rail button{width:40px;height:40px;border-radius:11px;transition:transform .16s,background .16s}.rail button:hover{transform:translateY(-5px) scale(1.08)}.canvasBottom{top:16px;bottom:auto;left:auto;right:16px;flex-direction:column;align-items:flex-end}.canvasBottom>span{display:none}.contextMenu{position:absolute;z-index:10;width:240px;max-height:min(420px,calc(100dvh - 24px));overflow:auto;padding:8px;background:#252b30f5;border:1px solid #47525b;border-radius:14px;box-shadow:0 16px 50px #0005;backdrop-filter:blur(18px)}.contextMenu>button{display:flex;width:100%;justify-content:flex-start;border:0;background:transparent}.menuTitle{display:flex;align-items:center;justify-content:space-between;padding:3px 6px 8px;font-size:11px;color:#aebac4}.menuTitle button{padding:3px 8px}.contextMenu label{display:block;padding:8px;font-size:11px}.contextMenu select{display:block;width:100%;padding:8px;margin-top:6px;background:#171c20;color:inherit;border:1px solid #46515a;border-radius:6px}.card footer{display:flex;align-items:center;justify-content:space-between;padding:7px 12px}.card footer button{padding:2px 8px;border:0;background:transparent}.app[data-theme=light]{color-scheme:light;color:#25323b;background:#f5f6f8}.app[data-theme=dark]{color-scheme:dark}.app[data-theme=light] .viewport{background-color:#f5f6f8;background-image:radial-gradient(circle,#b8c3cc 1px,transparent 1px)}.app[data-theme=light] .header,.app[data-theme=light] .rail,.app[data-theme=light] .controls,.app[data-theme=light] .contextMenu{background:#ffffffed;border-color:#d3dce3;box-shadow:0 8px 28px #3c52601a}.app[data-theme=light] button{background:#edf1f4;border-color:#d5dfe5;color:#344650}.app[data-theme=light] button:hover{background:#dde6eb}.app[data-theme=light] .card{background:#fff;border-color:#b8c8d2;box-shadow:0 10px 30px #31465918}.app[data-theme=light] .cardHead{background:#eef3f6;color:#405663}.app[data-theme=light] .cardBody input,.app[data-theme=light] .cardBody textarea,.app[data-theme=light] .contextMenu select{background:#f7f9fa;color:#243641;border-color:#d6e0e6}.app[data-theme=light] .nodeType,.app[data-theme=light] .disconnected,.app[data-theme=light] .menuTitle{color:#567047}.app[data-theme=light] .selected{border-color:#60863b}.app[data-theme=light] .card footer{border-color:#e1e7eb;color:#526878}.app[data-theme=light] .wires path{stroke:#628e3e;opacity:.9}.app[data-theme=light] .brand span{color:#60863b}.app[data-theme=light] .rail button[aria-pressed=true]{background:#d8e9c4;color:#34501d}@media(max-width:700px){.rail{left:50%;bottom:12px;gap:4px;height:54px;padding:5px 8px}.rail button{width:36px;height:36px}.canvasBottom{top:70px;right:10px;bottom:auto}.controls{gap:0}.controls button{padding:5px}}
.rail button{background:transparent;color:#bfcbd3}.rail button[aria-pressed=true]{background:#d9ef9b;color:#24301d}.app[data-theme=light] .rail button{background:transparent;color:#405663}.app[data-theme=light] .rail button[aria-pressed=true]{background:#d8e9c4;color:#34501d}