@@ -8,14 +8,14 @@ const initial:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意
const key = 'studio.infinite-canvas.v1' ;
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 ) ) ) ; }
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 ( ) {
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' ) ;
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 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 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 ) ;
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 { } 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 ( ( ) = > { 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 ) 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 ( ( ) = > { 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 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 ) ; 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 ) ; 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 ] ) ;
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 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 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 ) }
@@ -31,9 +31,9 @@ export default function InfiniteCanvas(){
< 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 >
< 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 >
< 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 >
< 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 } 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 . 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 ) 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 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 >
< 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' ? '镜头设计' : '创作起点' } < 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 = > < 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 . preventDefault ( ) ; e . stopPropagation ( ) ; setMenu ( null ) ; select ( c . id ) ; setEditing ( '' ) ; viewport . current ? . setPointerCapture ( e . pointerId ) ; 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 } onClick = { ( ) = > { if ( tool === 'hand' || editing !== c . id ) 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 } > { editing === c . id ? '点击上传参考图' : '参考图 · 点击编辑后上传' } < / span > < / > } < / div > : < div className = { s . cardBody } > < 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 } > ○ 模 型 服 务 未 接 入 · 仅 编 辑 提 示 词 < / 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 > ) }
< / 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 >
< / 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 ( 'prompt' ) ; 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 } > { 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 = { 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 ) } } / >