From d072aeeaa0b96ef1b1403a68c7e2a1c6345f8cbd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:12:47 +0800 Subject: [PATCH 01/22] Model canvas references, script nodes and agent task snapshots --- apps/studio/src/lib/canvas-document.ts | 64 +++++++++++++++++++ .../src/pages/infinite_canvas/index.tsx | 8 +-- apps/studio/tests/canvas-document.test.mjs | 23 +++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 apps/studio/src/lib/canvas-document.ts create mode 100644 apps/studio/tests/canvas-document.test.mjs diff --git a/apps/studio/src/lib/canvas-document.ts b/apps/studio/src/lib/canvas-document.ts new file mode 100644 index 0000000..29c6fba --- /dev/null +++ b/apps/studio/src/lib/canvas-document.ts @@ -0,0 +1,64 @@ +export type NodeKind = 'text' | 'image' | 'prompt' | 'script'; +export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number}; +export type CanvasNode = { + id:string;kind:NodeKind;title:string;text:string;x:number;y:number; + image?:string;width?:number;height?:number;style?:TextStyle; + operation?:'generate'|'upscale'|'script'; +}; +export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]}; +const kinds:NodeKind[]=['text','image','prompt','script']; +const object=(v:unknown):v is Record=>!!v&&typeof v==='object'&&!Array.isArray(v); +const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max; +const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max; +const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v); + +export function parseCanvasDocument(value:unknown):CanvasDocument { + const d=typeof value==='string'?JSON.parse(value):value; + if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确'); + const ids=new Set(); + const cards:CanvasNode[]=d.cards.map((n:unknown)=>{ + if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确'); + if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确'); + if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确'); + let style:TextStyle|undefined; + if(n.style!==undefined){ + if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确'); + style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})}; + } + ids.add(n.id as string); + return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})}; + }); + const pairs=new Set(); + const links:[string,string][]=d.links.map((l:unknown)=>{ + if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在'); + const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]]; + }); + return {version:1,cards,links}; +} + +export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{ + return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean); +} +export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument { + if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点'); + if(doc.links.some(([a,b])=>a===from&&b===to))return doc; + if(doc.links.length>=200)throw Error('最多 200 条引用'); + const seen=new Set();const pending=[to]; + while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));} + return {...doc,links:[...doc.links,[from,to]]}; +} +export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { + return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))}; +} +export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument { + if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确'); + const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id); + return parseCanvasDocument(result); +} +export function agentTask(doc:CanvasDocument,id:string){ + const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在'); + if(!node.text.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求'); + const references=referencedNodes(doc,id); + if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片'); + return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction:node.text,references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}}; +} diff --git a/apps/studio/src/pages/infinite_canvas/index.tsx b/apps/studio/src/pages/infinite_canvas/index.tsx index 9666c5a..8e8acc6 100644 --- a/apps/studio/src/pages/infinite_canvas/index.tsx +++ b/apps/studio/src/pages/infinite_canvas/index.tsx @@ -2,17 +2,15 @@ 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'; -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][]}; +import {parseCanvasDocument,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'; -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(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark'),[editing,setEditing]=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;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 恢复')}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=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)},[]); @@ -38,6 +36,6 @@ export default function InfiniteCanvas(){
● {status}
{Math.round(zoom*100)}%
{menu&&
{active?active.title:'添加到画布'}
{active?<>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=>)}:<>}
} {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)}}/> - {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('无法导入:项目格式错误或文件过大')}}}/> + {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('无法导入:项目格式错误或文件过大')}}}/> ; } diff --git a/apps/studio/tests/canvas-document.test.mjs b/apps/studio/tests/canvas-document.test.mjs new file mode 100644 index 0000000..7c0fd99 --- /dev/null +++ b/apps/studio/tests/canvas-document.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts'; +const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50}; +const doc={version:1,cards:[note],links:[]}; +test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{ + assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc); + for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad)); +}); +test('derived image and script nodes preserve directed references across save and restore',()=>{ + const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'}; + const next=deriveNode(doc,'brief',picture); + const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'}))); + assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture'); + assert.throws(()=>connectNodes(restored,'script','brief'),/循环/); + const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2); +}); +test('agent task captures current referenced content and validates upscale inputs',()=>{ + const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'}); + assert.equal(agentTask(next,'out').references[0].text,'清晨的城市'); + assert.equal(agentTask(next,'out').result.targetId,'out'); + assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/); +}); -- 2.52.0 From bd8ccbb4a14be51e4515dc332f722f6bf6227bad Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:27:34 +0800 Subject: [PATCH 02/22] Add selected-node editing, reference ports and fixed canvas composer --- apps/studio/scripts/infinite-canvas-smoke.cjs | 154 +++++++++--------- .../src/components/canvas/CanvasComposer.tsx | 9 + .../src/components/canvas/CanvasNodeView.tsx | 16 ++ apps/studio/src/lib/canvas-document.ts | 9 +- .../src/pages/infinite_canvas/index.tsx | 32 +++- .../src/styles/canvas-composer.module.css | 2 + apps/studio/src/styles/canvas-node.module.css | 3 + apps/studio/tests/canvas-document.test.mjs | 7 + 8 files changed, 145 insertions(+), 87 deletions(-) create mode 100644 apps/studio/src/components/canvas/CanvasComposer.tsx create mode 100644 apps/studio/src/components/canvas/CanvasNodeView.tsx create mode 100644 apps/studio/src/styles/canvas-composer.module.css create mode 100644 apps/studio/src/styles/canvas-node.module.css diff --git a/apps/studio/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs index 495a538..997bd6b 100644 --- a/apps/studio/scripts/infinite-canvas-smoke.cjs +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -1,91 +1,93 @@ -// Supply authorized browser state for the server. Local acceptance was explicitly authorized for this revision. +// Isolated browser context against the designated deployed server only. const {chromium}=require('playwright-core'); const assert=require('node:assert/strict'); const fs=require('node:fs/promises'); (async()=>{ - const base=process.env.BASE_URL; - assert.ok(['http://8.211.154.134:18131','http://127.0.0.1:3024'].includes(base),'Use the designated server or the explicitly authorized local preview'); + const base=process.env.BASE_URL||'http://8.211.154.134:18131'; + assert.equal(base,'http://8.211.154.134:18131'); 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+'/infinite_canvas'); - assert.equal(new URL(page.url()).pathname,'/infinite_canvas','Login required; authenticate without weakening protection'); - await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor(); - await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click(); - // A fresh context is used so this test cannot overwrite a human browser draft. + const page=await context.newPage(),errors=[];let choosers=0; + page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++); const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1'))); const node=id=>page.locator(`[data-node-id="${id}"]`); + const content=id=>node(id).locator('[data-node-content]'); + const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value}); + const composer=page.getByRole('region',{name:'节点编辑器'}); 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(':scope > div').nth(1),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); - } - } - // Double-click content enters editing without moving or creating nodes. - const nodeCount=(await snapshot()).cards.length; - const before=(await snapshot()).cards.find(c=>c.id==='brief'); - await node('brief').locator('textarea').dblclick({force:true}); - assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),null); - await node('brief').locator('textarea').fill('双击后直接编辑'); - assert.equal((await snapshot()).cards.length,nodeCount); - assert.equal((await snapshot()).cards.find(c=>c.id==='brief').x,before.x); - assert.equal((await snapshot()).cards.find(c=>c.id==='brief').y,before.y); + async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()} + await page.goto(base+'/infinite_canvas'); + await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click(); + await page.getByRole('button',{name:'适应画布',exact:true}).click(); + await content('brief').click({position:{x:20,y:20}}); + assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false'); + await page.waitForTimeout(700); + await content('brief').click({position:{x:20,y:20}}); + await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑'); + await waitField('brief','text','选择后再次点击进入编辑'); + await node('brief').getByRole('button',{name:'加粗',exact:true}).click(); + assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true); await page.keyboard.press('Escape'); - const chooser=page.waitForEvent('filechooser'); - await node('reference').getByRole('button',{name:/替换.*图片/}).dblclick(); - const imagePicker=await chooser; - await imagePicker.setFiles({name:'replacement.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jC1sAAAAASUVORK5CYII=','base64')}); - await page.waitForFunction(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(c=>c.id==='reference').image?.startsWith('data:image/png;base64,')); - assert.equal((await snapshot()).cards.length,nodeCount); + const before=(await snapshot()).cards.find(n=>n.id==='brief'); + const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a); + await drag(content('brief'),45,25); + const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z); + assert.equal(await node('brief').getAttribute('data-editing'),'false'); + + await content('reference').click();assert.equal(await composer.count(),0); + await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker'); + const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox(); + await page.getByRole('button',{name:'缩小',exact:true}).click(); + const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox(); + close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image); + await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click(); + const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale); + const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出 Agent 任务',exact:true}).click(); + const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8')); + assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image); + + await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click(); + await content('brief').click({position:{x:20,y:20}}); + await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click(); + await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click(); + const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script); + await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜'); + await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,''); + await composer.getByRole('button',{name:'画布参考',exact:true}).click(); + await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click(); + assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id)); + await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click(); + assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id)); await page.keyboard.press('Escape'); - assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),'');await node('brief').getByRole('button',{name:/^编辑 /}).click();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); - await drag(node('brief').locator('textarea'),20,10);const inEdit=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(inEdit.x,edited.x);assert.equal(inEdit.y,edited.y);await node('brief').getByRole('button',{name:/^完成编辑 /}).click();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(); + await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click(); + await page.getByRole('button',{name:'删除节点',exact:true}).click(); + assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference'))); + await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id)); + await page.keyboard.press('Escape'); + await content('brief').click({position:{x:20,y:20}}); + const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width; + await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30); + assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320)); + await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth); + await page.getByRole('button',{name:'重做',exact:true}).click(); 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); - // Zoom must preserve the world coordinate beneath the pointer. - const vp=await viewport.boundingBox();assert.equal(vp.x,0);assert.equal(vp.y,0);assert.equal(vp.width,1600);assert.equal(vp.height,1100); - const anchor={x:vp.x+vp.width*.65,y:vp.y+vp.height*.25}; - const worldAt=()=>page.locator('[class*=world]').evaluate((el,p)=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(p.x-m.e)/m.a,y:(p.y-m.f)/m.d}},anchor); - const point=await worldAt();await page.mouse.move(anchor.x,anchor.y);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control'); - await page.waitForFunction(old=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1.view')).zoom!==JSON.parse(old).zoom,view); - const pointAfter=await worldAt();close(point.x,pointAfter.x);close(point.y,pointAfter.y); - await page.getByRole('button',{name:'选择工具',exact:true}).click(); - await page.getByRole('button',{name:'添加笔记',exact:true}).click(); - assert.equal((await snapshot()).cards.length,saved.cards.length+1); - await page.getByRole('button',{name:/操作$/}).last().click(); - await page.getByRole('button',{name:'复制节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2); - await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1); - await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2); - await page.getByRole('button',{name:'重做',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1); - if(await page.getByRole('button',{name:'关闭画布操作',exact:true}).isVisible())await page.getByRole('button',{name:'关闭画布操作',exact:true}).click(); - // Return to the saved three-node document through undo. - await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click(); - await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor(); - const dock=await page.getByRole('navigation',{name:'画布工具'}).boundingBox();assert.ok(dock.y>950);assert.ok(dock.x>500); - await page.getByRole('button',{name:'切换明暗主题'}).click();assert.equal(await page.locator('main').getAttribute('data-theme'),'light');await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light'); - if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-light.png'}); - await page.getByRole('button',{name:'切换明暗主题'}).click(); - 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'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));const mobile=await viewport.boundingBox();assert.equal(mobile.width,390);assert.equal(mobile.height,844);await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png'});} - assert.deepEqual(errors,[]);console.log('Passed: two-node drag at two scales, inline input, resize, undo/redo, wires, pan, reload, pointer zoom, CRUD and full-screen desktop/mobile'); + await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up(); + assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true); + const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}}); + const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control'); + await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z); + const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y); + const saved=await snapshot();await page.reload();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved); + await page.getByRole('button',{name:'切换明暗主题',exact:true}).click();await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light'); + if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});} + await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844); + if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'}); + assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload and mobile bounds'); }finally{await browser.close()} })().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/apps/studio/src/components/canvas/CanvasComposer.tsx b/apps/studio/src/components/canvas/CanvasComposer.tsx new file mode 100644 index 0000000..7520c79 --- /dev/null +++ b/apps/studio/src/components/canvas/CanvasComposer.tsx @@ -0,0 +1,9 @@ +import {useEffect,useState} from 'react'; +import {ImagePlus,FileText,Plus,X,Maximize,Download} from 'lucide-react'; +import type {CanvasNode} from '../../lib/canvas-document'; +import s from '../../styles/canvas-composer.module.css'; +type Props={pickerRequest:number;feedback:string;node:CanvasNode;nodes:CanvasNode[];references:CanvasNode[];onChange:(text:string)=>void;onRename:(title:string)=>void;onBeginEdit:()=>void;onUpload:()=>void;onAddReference:(id:string)=>void;onRemoveReference:(id:string)=>void;onExport:()=>void;onClose:()=>void}; +export default function CanvasComposer(p:Props){const [expanded,expand]=useState(false),[choosing,choose]=useState(false);useEffect(()=>{if(p.pickerRequest)choose(true)},[p.pickerRequest]);return
{p.node.kind==='image'?:} p.onRename(e.target.value)}/>
{p.node.kind==='image'&&}
+ {choosing&&
{p.nodes.filter(n=>n.id!==p.node.id&&!p.references.some(r=>r.id===n.id)).map(n=>)}{p.nodes.length<2&&先在画布上添加一个参考节点}
} + {!!p.references.length&&
{p.references.map((n,i)=>
{n.image?{n.title}/:}{i+1}{n.title}
)}
} +