@@ -0,0 +1,35 @@
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 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 } ;
type Doc = { version :1 ; cards :Card [ ] ; links : [ string , string ] [ ] } ;
const initial :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 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.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 ( '' ) ;
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 } | 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 恢复' ) } 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 ] ) ;
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 change ( field : 'title' | 'text' , value :string ) { commit ( { . . . doc , cards :doc.cards.map ( c = > c . id === selected ? { . . . c , [ field ] : value } : c ) } ) }
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 + 320 ) ) - minX , h = Math . max ( . . . doc . cards . map ( c = > c . y + 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 } / > < 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 } ref = { viewport } onWheel = { e = > { if ( e . ctrlKey || e . metaKey ) { setZoom ( z = > Math . max ( . 2 , Math . min ( 2 , z - e . deltaY * . 002 ) ) ) } else setPan ( p = > ( { x :p.x - e . deltaX , y :p.y - e . deltaY } ) ) } } onPointerDown = { e = > { if ( e . target === e . currentTarget || tool === 'hand' ) { 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 ? { . . . 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' ) { setPast ( p = > [ . . . p . slice ( - 29 ) , d . before ] ) ; setFuture ( [ ] ) } drag . current = null } } onPointerCancel = { ( ) = > { 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 + 320 } ${ from . y + 150 } C ${ from . x + 365 } ${ from . y + 150 } , ${ to . x - 45 } ${ to . y + 150 } , ${ to . x } ${ to . y + 150 } ` } / > } ) } < / svg >
{ doc . cards . map ( c = > < article key = { c . id } className = { ` ${ s . card } ${ selected === c . id ? s . selected : '' } ` } style = { { left :c.x , top :c.y } } onClick = { ( ) = > select ( c . id ) } > < div className = { s . cardHead } onPointerDown = { e = > { if ( tool === 'hand' ) 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 = { ( ) = > { 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 > < p > { c . text || '选择节点,在右侧写下你的想法。' } < / p > < / div > } { c . kind === 'prompt' && < div className = { s . disconnected } > ○ 模 型 服 务 未 接 入 · 仅 编 辑 提 示 词 < / div > } < footer > { c . kind === 'image' ? '视觉素材' : c . kind === 'prompt' ? '镜头设计' : '创作起点' } < span className = { s . port } / > < / footer > < / 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 = { ( ) = > setZoom ( z = > Math . max ( . 2 , z - . 1 ) ) } > < Minus size = { 16 } / > < / button > < span > { Math . round ( zoom * 100 ) } % < / span > < button aria-label = "放大" onClick = { ( ) = > setZoom ( z = > Math . min ( 2 , z + . 1 ) ) } > < Plus size = { 16 } / > < / button > < button aria-label = "适应画布" onClick = { fit } > < Maximize size = { 16 } / > < / button > < / div > < / div > < / section >
< aside className = { s . inspector } > < 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 } onChange = { e = > change ( 'title' , e . target . value ) } / > < / label > < label > { active . kind === 'prompt' ? '画面描述' : '笔记内容' } < textarea maxLength = { 10000 } value = { active . text } 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 . 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 >
< 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 > ;
}