@@ -2,39 +2,55 @@ 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' ;
import s from '../../styles/infinite-canvas.module.css' ;
import { parseCanvasDocument , type CanvasNode as Card , type CanvasDocument as Doc } from '../../lib/canvas-document ';
import CanvasNodeView from '../../components/canvas/CanvasNodeView ';
import CanvasComposer from '../../components/canvas/CanvasComposer' ;
import { parseCanvasDocument , deriveNode , connectNodes , removeCanvasNode , referencedNodes , agentTask , type CanvasNode as Card , type CanvasDocument as Doc } from '../../lib/canvas-document' ;
const example :Doc = { version :1 , cards : [ { id : 'brief' , kind : 'text' , title : '01 / 创意简报' , text : '把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。' , x :70 , y :140 } , { id : 'reference' , kind : 'image' , title : '02 / 视觉参考' , text : '晨光 · 建筑 · 胶片质感\n上传你的参考图片' , x :460 , y :80 } , { id : 'shot' , kind : 'prompt' , title : '03 / 镜头构思' , text : '清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。' , x :850 , y :160 } ] , links : [ [ 'brief' , 'reference' ] , [ 'reference' , 'shot' ] ] } ;
const initial :Doc = { version :1 , cards : [ ] , links : [ ] } ;
const key = 'studio.infinite-canvas.v1' ;
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 ( '' ) , [ menu , setMenu ] = useState < { x :number ; y :number } | null > ( null ) , [ theme , setTheme ] = useState < 'light' | 'dark' > ( 'dark' ) , [ editing , setEditing ] = useState ( '' ) ;
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' ) , [ 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 ) ;
const active = doc . cards . find ( c = > c . id === selected ) ;
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 { } 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 . key === 'Escape' ) { setMenu ( null ) ; setEditing ( '' ) ; 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 ( ( ) = > { const keys = ( e :KeyboardEvent ) = > { if ( e . key === 'Escape' ) { setMenu ( null ) ; setRefMenu ( '' ) ; setEditing ( '' ) ; 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 : ( ( 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 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 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 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 ) } ) }
function derive ( source :string , kind :Card [ 'kind' ] , operation :Card [ 'operation' ] = 'generate' ) {
const parent = doc . cards . find ( n = > n . id === source ) ; if ( ! parent ) return ;
const id = Array . from ( crypto . getRandomValues ( new Uint8Array ( 16 ) ) , b = > b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
const title = operation === 'upscale' ? '图片高清' : kind === 'script' ? '脚本生成器' : kind === 'image' ? '图片节点' : '文本节点' ;
try { commit ( deriveNode ( doc , source , { id , kind , operation , title , text : '' , x :parent.x + ( parent . width || 320 ) + 150 , y :parent.y , width :360 , height :300 } ) ) ; editNode ( id ) ; setComposerTarget ( id ) } catch ( e ) { setStatus ( e instanceof Error ? e . message : '无法创建节点' ) }
}
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 } data-theme = { theme } > < Head > < title > 无 限 画 布 · Studio < / title > < meta name = "description" content = "在无限画布上组织灵感、参考素材与镜头提示词" / > < / Head >
< header className = { s . header } > < a href = "/workspace" className = { s . back } aria-label = "返回 Studio" > < ArrowLeft size = { 18 } / > < / a > < a href = "/workspace" 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 } > { ! 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 ) ; if ( e . target === e . currentTarget ) setEditing ( '' ) ; 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 . 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 = > < article key = { c . id } className = { ` ${ s . card } ${ selected === c . id ? s . selected : '' } ${ editing === c.id?s.editing :s.preview } ` } data-node-id = { c . id } style = { { left :c.x , top :c.y , width :c.width || 320 , height :c.height || 350 } } onPointerDown = { e = > { if ( editing === c . id || tool === 'hand' || e . button !== 0 || ( e . target as HTMLElement ) . closest ( 'button' ) ) return ; e . stopPropagation ( ) ; setMenu ( null ) ; select ( c . id ) ; setEditing ( '' ) ; drag . current = { id :c.id , x :e.clientX , y :e.clientY , ox :c.x , oy :c.y , before :doc } } } 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 } role = "button" tabIndex = { 0 } aria-label = { ` 替换 ${ c . title } 图片 ` } onDoubleClick = { e = > { e . stopPropagation ( ) ; select ( c . id ) ; setEditing ( c . id ) ; setTool ( 'select' ) ; setMenu ( null ) ; file . current ? . click ( ) } } onKeyDown = { e = > { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; select ( c . id ) ; setEditing ( 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 } onDoubleClick = { e = > { e . stopPropagation ( ) ; if ( editing === c . id ) return ; select ( c . id ) ; setEditing ( c . id ) ; setTool ( 'select' ) ; setMenu ( null ) ; const body = e . currentTarget ; const name = body . querySelector ( 'input' ) ; const field = name && e . clientY <= name . getBoundingClientRect ( ) . bottom?name :body.querySelector ( 'textarea' ) ; requestAnimationFrame ( ( ) = > field ? . focus ( ) ) } } > < span className = { s . nodeType } > { c . kind === 'prompt' ? 'PROMPT / 提示词' : 'NOTE / 灵感笔记' } < / span > < input readOnly = { editing !== c . id } tabIndex = { editing === c . id ? 0 : - 1 } aria-label = { ` ${ c . title } 名称 ` } value = { c . title } maxLength = { 200 } onFocus = { beginEdit } onChange = { e = > change ( 'title' , e . target . value , c . id ) } / > < textarea readOnly = { editing !== c . id } tabIndex = { editing === c . id ? 0 : - 1 } 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 } > 提 示 词 已 备 好 , 交 给 你 的 Agent 继 续 创 作 。 < / div > } < footer > { c . kind === 'image' ? '视觉素材' : c . kind === 'prompt' ? '镜头设计' : '创作起点' } < button aria-label = { ` ${ editing === c . id ? '完成编辑' : '编辑' } ${ c . title } ` } onClick = { e = > { e . stopPropagation ( ) ; select ( c . id ) ; setEditing ( editing === c . id ? '' : c . id ) } } > { editing === c . id ? '完成' : '编辑' } < / button > < 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 > ) }
{ 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 > < / 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 } > { 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 ( 'prom pt' ) ; setMenu ( null ) } } > 添 加 提 示 词 < / button > < / > } < / div > } < / 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 } > { 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 ( 'scri pt' ) ; 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 ) } ) } 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 > ;