Add light and dark canvas themes with floating dock and contextual actions
This commit is contained in:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user