From 2cc6f1f5433331758e825cc5cf3f5246d6101475 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 31 Oct 2026 01:23:41 +0800 Subject: [PATCH] Place derived canvas nodes without covering existing content --- apps/studio/scripts/infinite-canvas-smoke.cjs | 10 ++++++-- apps/studio/src/lib/canvas-document.ts | 10 ++++++-- apps/studio/tests/canvas-document.test.mjs | 24 +++++++++++++++++++ .../waterfall-wiki/src/lib/canvas-document.ts | 10 ++++++-- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/studio/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs index f78e547..aab45e1 100644 --- a/apps/studio/scripts/infinite-canvas-smoke.cjs +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -6,15 +6,18 @@ const fs=require('node:fs/promises'); 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,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true}); + let page; try{ const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined}); - const page=await context.newPage(),errors=[];let choosers=0; + page=await context.newPage();const 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 capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}}; + async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.xother.x&&box.yother.y,false,`${id} overlaps ${card.id}`)}} const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`); 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'); @@ -38,6 +41,7 @@ const fs=require('node:fs/promises'); 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 capture('canvas-composer-desktop'); 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); + await unobstructed(upscale.id); const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',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); @@ -55,6 +60,7 @@ const fs=require('node:fs/promises'); 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 unobstructed(script.id); 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(); @@ -89,5 +95,5 @@ const fs=require('node:fs/promises'); 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(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()} })().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/apps/studio/src/lib/canvas-document.ts b/apps/studio/src/lib/canvas-document.ts index f7f55da..d170267 100644 --- a/apps/studio/src/lib/canvas-document.ts +++ b/apps/studio/src/lib/canvas-document.ts @@ -59,8 +59,14 @@ export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { } 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); + const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0]; + for(let attempt=0;attempt<=doc.cards.length+1;attempt++){ + const blockers=doc.cards.filter(n=>target.xn.x&&target.yn.y); + if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id)); + const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64)); + target.y=nextY>100000?-100000:nextY; + } + throw Error('请移动现有节点后再创建引用节点'); } export function agentTask(doc:CanvasDocument,id:string){ const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在'); diff --git a/apps/studio/tests/canvas-document.test.mjs b/apps/studio/tests/canvas-document.test.mjs index 26db818..cd06691 100644 --- a/apps/studio/tests/canvas-document.test.mjs +++ b/apps/studio/tests/canvas-document.test.mjs @@ -36,6 +36,30 @@ test('editing an image passes the current image to the external agent',()=>{ assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image); }); +const overlaps=(a,b)=>a.xb.x&&a.yb.y; +test('branching the example keeps the source image accessible after image and script derivation',()=>{ + const scene={version:1,cards:[ + {...note,x:70,y:140}, + {...note,id:'reference',kind:'image',x:460,y:80}, + {...note,id:'shot',kind:'prompt',x:850,y:160}, + ],links:[['brief','reference'],['reference','shot']]}; + const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300}); + const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300}); + assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged'); + for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`); + assert.equal(referencedNodes(result,'hd')[0].id,'reference'); + assert.equal(referencedNodes(result,'script')[0].id,'brief'); + assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result); +}); +test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{ + const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50}); + assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50}); + const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]}; + const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300}); + assert.equal(overlaps(result.cards[0],result.cards[1]),false); + assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result); +}); + test('agent and browser parse the same canvas document contract',()=>{ assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8')); }); diff --git a/apps/waterfall-wiki/src/lib/canvas-document.ts b/apps/waterfall-wiki/src/lib/canvas-document.ts index f7f55da..d170267 100644 --- a/apps/waterfall-wiki/src/lib/canvas-document.ts +++ b/apps/waterfall-wiki/src/lib/canvas-document.ts @@ -59,8 +59,14 @@ export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { } 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); + const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0]; + for(let attempt=0;attempt<=doc.cards.length+1;attempt++){ + const blockers=doc.cards.filter(n=>target.xn.x&&target.yn.y); + if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id)); + const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64)); + target.y=nextY>100000?-100000:nextY; + } + throw Error('请移动现有节点后再创建引用节点'); } export function agentTask(doc:CanvasDocument,id:string){ const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');