From f5422b154129881a1e3720769e4660bfe4bb72d4 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 00:26:01 +0800 Subject: [PATCH 1/3] Make canvas nodes editable resizable tools with anchored navigation --- apps/studio/scripts/infinite-canvas-smoke.cjs | 51 ++++++++++++++++++- .../src/pages/infinite_canvas/index.tsx | 28 ++++++---- .../src/styles/infinite-canvas.module.css | 3 ++ docs/plans/canvas-rebuild.md | 16 ++++++ 4 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 docs/plans/canvas-rebuild.md diff --git a/apps/studio/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs index d994e71..61bae89 100644 --- a/apps/studio/scripts/infinite-canvas-smoke.cjs +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -1,4 +1,51 @@ -// Run against an already started Studio. BASE_URL is the origin, including port. +// Server-only acceptance. Supply an existing authorized browser state; never bypass login. 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}); +const fs=require('node:fs/promises'); +(async()=>{ + const base=process.env.BASE_URL; + assert.equal(base,'http://8.211.154.134:18131','Acceptance requires the designated server'); + const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true}:{channel:'chrome',headless:true}); + try{ + const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined}); + const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.goto(base+'/studio/infinite_canvas'); + assert.equal(new URL(page.url()).pathname,'/studio/infinite_canvas','Login required; authenticate without weakening protection'); + await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor(); + // A fresh context is used so this test cannot overwrite a human browser draft. + const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1'))); + const node=id=>page.locator(`[data-node-id="${id}"]`); + const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`); + async function drag(handle,dx,dy){const box=await handle.boundingBox();assert.ok(box);await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();await page.mouse.move(box.x+box.width/2+dx,box.y+box.height/2+dy,{steps:12});await page.mouse.up()} + for(const scale of [0,1]){ + await page.getByRole('button',{name:'适应画布',exact:true}).click(); + if(scale)await page.getByRole('button',{name:'放大',exact:true}).click(); + const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a); + for(const id of ['brief','reference']){ + const before=(await snapshot()).cards.find(c=>c.id===id); + await drag(node(id).locator('[title="拖动手柄 · 移动工具"]'),45,25); + const after=(await snapshot()).cards.find(c=>c.id===id);close(after.x-before.x,45/z);close(after.y-before.y,25/z); + } + } + const before=(await snapshot()).cards.find(c=>c.id==='brief'); + await node('brief').locator('textarea').fill('真实工具内编辑 · 输入不能移动节点'); + const edited=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(edited.x,before.x);assert.equal(edited.y,before.y); + const resize=node('brief').getByRole('button',{name:/调整/});await drag(resize,60,40); + assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320); + await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(c=>c.id==='brief').width,undefined); + await page.getByRole('button',{name:'重做',exact:true}).click();assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320); + // Each SVG endpoint must agree with the actual transformed node edge, after resize and pan. + async function wires(){const mismatch=await page.evaluate(()=>{ + return [...document.querySelectorAll('svg path')].filter(p=>p.getAttribute('d')?.startsWith('M ')).slice(0,1).some(p=>{const d=p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),world=p.closest('div').getBoundingClientRect();const matrix=p.getScreenCTM();const pt=new DOMPoint(d[0],d[1]).matrixTransform(matrix);const card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(pt.x-card.right)>3||Math.abs(pt.y-(card.top+card.height/2))>3}); + });assert.equal(mismatch,false)} + // The first link starts at brief; check it specifically. + await wires(); + await page.getByRole('button',{name:'移动画布',exact:true}).click(); + const viewport=page.locator('[class*="viewport"]');const box=await viewport.boundingBox();await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+100,box.y+80,{steps:8});await page.mouse.up(); + await wires(); + const saved=await snapshot(),view=await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view')); + await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.deepEqual(await snapshot(),saved);assert.equal(await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view')),view); + if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-rebuild.png'});} + assert.deepEqual(errors,[]);console.log('Passed: two-node drag at two scales, inline input, resize, undo/redo, wires, pan and reload'); + }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 index 518764f..4bc938f 100644 --- a/apps/studio/src/pages/infinite_canvas/index.tsx +++ b/apps/studio/src/pages/infinite_canvas/index.tsx @@ -2,33 +2,39 @@ 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 Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string;width?:number;height?:number}; type Doc={version:1;cards:Card[];links:[string,string][]}; 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)));} +function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&& (c.width===undefined||(Number.isFinite(c.width)&&c.width>=260&&c.width<=1200))&&(c.height===undefined||(Number.isFinite(c.height)&&c.height>=300&&c.height<=1200))&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));} export default function InfiniteCanvas(){ const [doc,setDoc]=useState(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 file=useRef(null),json=useRef(null),viewport=useRef(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null); const active=doc.cards.find(c=>c.id===selected); - useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){setDoc(d);setStatus('已恢复本机草稿')}}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}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{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]); useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]); + useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]); + useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]); + useEffect(()=>{const keys=(e:KeyboardEvent)=>{if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)}); + 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+' 副本',x:active.x+40,y:active.y+40}]});select(id)} function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)} function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'参考素材':'生成提示词',text:'',x:((rect?.width||700)/2-pan.x)/zoom-160,y:(160-pan.y)/zoom,image}]});select(id)} - function change(field:'title'|'text',value:string){commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,[field]:value}:c)})} + function change(field:'title'|'text',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))} + function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])} function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))} function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))} - function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+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 fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})} function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)} return
无限画布 · 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)}%
-