From 8c5d8389444230630204dc1a9c046a34b4456307 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 29 Oct 2026 19:50:59 +0800 Subject: [PATCH] Add infinite canvas workspace with local drafts and connected nodes --- apps/studio/scripts/infinite-canvas-smoke.cjs | 4 +++ .../src/pages/infinite_canvas/index.tsx | 35 +++++++++++++++++++ .../src/styles/infinite-canvas.module.css | 1 + docs/plans/infinite-canvas.md | 23 ++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 apps/studio/scripts/infinite-canvas-smoke.cjs create mode 100644 apps/studio/src/pages/infinite_canvas/index.tsx create mode 100644 apps/studio/src/styles/infinite-canvas.module.css create mode 100644 docs/plans/infinite-canvas.md diff --git a/apps/studio/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs new file mode 100644 index 0000000..d994e71 --- /dev/null +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -0,0 +1,4 @@ +// Run against an already started Studio. BASE_URL is the origin, including port. +const {chromium}=require('playwright-core'); +const assert=require('node:assert/strict'); +(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto((process.env.BASE_URL||'http://localhost:3018')+'/studio/infinite_canvas');await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'添加笔记',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByLabel('节点名称').fill('Smoke note');await page.getByLabel('笔记内容').fill('A saved idea');await page.reload();await page.getByText('Smoke note',{exact:true}).first().waitFor();assert.equal(await page.locator('article').count(),4);await page.getByText('Smoke note',{exact:true}).first().click();await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByRole('button',{name:'适应画布'}).click();const card=page.locator('article').last();const before=await card.getAttribute('style');const box=await card.locator('div').first().boundingBox();await page.mouse.move(box.x+70,box.y+20);await page.mouse.down();await page.mouse.move(box.x+120,box.y+45,{steps:5});await page.mouse.up();assert.notEqual(await card.getAttribute('style'),before);const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出项目'}).click();assert.equal((await download).suggestedFilename(),'studio-canvas.json');if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-desktop.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png',fullPage:true});assert.deepEqual(errors,[]);console.log('Canvas browser smoke passed: create, edit, restore, delete, undo, fit, export, mobile.')}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/apps/studio/src/pages/infinite_canvas/index.tsx b/apps/studio/src/pages/infinite_canvas/index.tsx new file mode 100644 index 0000000..518764f --- /dev/null +++ b/apps/studio/src/pages/infinite_canvas/index.tsx @@ -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(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState([]),[future,setFuture]=useState([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''); + const file=useRef(null),json=useRef(null),viewport=useRef(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
无限画布 · Studio +
STUDIO✳工作空间 未命名的灵感宇宙本机草稿
+
+
YOUR IDEAS, CONNECTED

无限画布 / Infinite canvas

从一个灵感,连接所有可能。

+
{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}}> +
+ {doc.cards.map(c=>
select(c.id)}>
{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}}}>{c.kind==='text'?:c.kind==='image'?:} {c.title}⠿
{c.kind==='image'?
{select(c.id);file.current?.click()}}>{c.image?{c.title}/:<>
REF.光的形状LIGHT / SPACE / EMOTION
示例占位 · 点击上传参考图}
:
{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}

{c.text||'选择节点,在右侧写下你的想法。'}

}{c.kind==='prompt'&&
○ 模型服务未接入 · 仅编辑提示词
}
{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}
)} +
● {status}
{Math.round(zoom*100)}%
+