Compare commits

..

6 Commits

Author SHA1 Message Date
codex-bot f3e9f5a3fb Use isolated Wiki media fixtures in Studio release smoke checks 2026-10-30 00:59:58 +08:00
codex-bot 922c7bd0d0 Merge pull request 'Move repository images and embedded template bitmaps to S3' (#23) from codex/s3-media into main
studio CI and deployment / verify-and-deploy (push) Failing after 4m49s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 18m8s
2026-10-29 16:43:28 +00:00
codex-bot 3f8bd11a17 Merge pull request 'Rebuild Studio infinite canvas as full-screen movable tools' (#24) from codex/canvas-rebuild into main
studio CI and deployment / verify-and-deploy (push) Failing after 8m37s
2026-10-29 16:43:24 +00:00
codex-bot e2bc4ae6bd Add light and dark canvas themes with floating dock and contextual actions 2026-10-30 00:42:33 +08:00
codex-bot ef66f687bd Expand dotted canvas to full screen and verify real tool gestures 2026-10-30 00:34:49 +08:00
codex-bot f5422b1541 Make canvas nodes editable resizable tools with anchored navigation 2026-10-30 00:26:01 +08:00
10 changed files with 142 additions and 24 deletions
+72 -2
View File
@@ -1,4 +1,74 @@
// Run against an already started Studio. BASE_URL is the origin, including port.
// Supply authorized browser state for the server. Local acceptance was explicitly authorized for this revision.
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.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 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);
// 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');
}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
+4 -4
View File
@@ -6,13 +6,13 @@ const standardId='00000000-0000-4000-8000-000000000003';
const seriesId='00000000-0000-4000-8000-000000000004';
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
let reads=0;
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
let reads=0,mediaReads=0;
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
const headers={cookie:'session=smoke-test'};
for(const id of [designId,standardId]){
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
+2 -2
View File
@@ -4,7 +4,7 @@ import type {Parameters} from './layout-parameters';
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
return sources;
}
@@ -14,7 +14,7 @@ export async function embedSvgMedia(req:NextApiRequest,svg:string){
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
let total=0;
for(const path of paths){
const response=await fetch('http://waterfall-wiki:3000'+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const mime=response.headers.get('content-type')?.split(';')[0];
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
+23 -16
View File
@@ -1,34 +1,41 @@
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 {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};
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<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 [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(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark');
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;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{}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)},[]);
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);return}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)});
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.theme',theme)}catch{}},[theme,ready]);
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.slice(0,197)+' 副本',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 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:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
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 <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>
return <main className={s.app} data-theme={theme}><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}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><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>
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){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?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);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+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} data-node-id={c.id} style={{left:c.x,top:c.y,width:c.width||320,height:c.height||350}} onClick={()=>select(c.id)}><div className={s.cardHead} title="拖动手柄 · 移动工具" aria-label={`移动 ${c.title}`} onPointerDown={e=>{if(tool==='hand'||e.button!==0)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={()=>{if(tool==='hand')return;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><input aria-label={`${c.title} 名称`} value={c.title} maxLength={200} onFocus={beginEdit} onChange={e=>change('title',e.target.value,c.id)}/><textarea aria-label={`${c.title} 内容`} value={c.text} maxLength={10000} placeholder="在工具内直接编辑…" onFocus={beginEdit} onChange={e=>change('text',e.target.value,c.id)}/></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<button aria-label={`${c.title} 操作`} onClick={e=>{e.stopPropagation();select(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}}>•••</button></footer><span className={s.inputPort}/><span className={s.outputPort}/><button className={s.resize} aria-label={`调整 ${c.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}}>↘</button></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={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{active?<><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('');setMenu(null)}}>删除节点</button><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 disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button 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}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('prompt');setMenu(null)}}>添加提示词</button></>}</div>}</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>;
File diff suppressed because one or more lines are too long
+6
View File
@@ -19,3 +19,9 @@ test('SVG export rejects unavailable or non-image objects',async()=>{
test('SVG exports without managed images do not access storage',async()=>{
assert.equal(await embedSvgMedia({headers:{}},'<svg><text>Title</text></svg>'),'<svg><text>Title</text></svg>');
});
test('SVG export uses the configured isolated Wiki origin',async()=>{
const original=globalThis.fetch,previous=process.env.STUDIO_WIKI_ORIGIN;
try{process.env.STUDIO_WIKI_ORIGIN='http://127.0.0.1:19000';globalThis.fetch=async url=>{assert.equal(url,'http://127.0.0.1:19000/api/media/'+id);return new Response('fixture',{headers:{'content-type':'image/png'}})};assert.ok((await embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`)).includes('data:image/png;base64,'));}
finally{globalThis.fetch=original;if(previous===undefined)delete process.env.STUDIO_WIKI_ORIGIN;else process.env.STUDIO_WIKI_ORIGIN=previous;}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+27
View File
@@ -0,0 +1,27 @@
# Infinite canvas rebuild
The previous workspace displays node bodies but keeps editing in a fixed inspector. Header dragging exists in source, but the user's observed failure has not yet been reproduced in a browser. Do not claim otherwise.
References: https://docs.comfy.org/interface/overview (node canvas, parameters, canvas navigation); https://www.runninghub.cn/runninghub-api-doc-en/ (workflow API requires a real connected service). Image searches covered ComfyUI and RunningHub workspace layouts.
Implementation plan:
1. Keep document coordinates independent from viewport translation and scale. Scale around the pointer, or viewport center for toolbar buttons.
2. Put usable text/prompt editors and image actions inside movable nodes. Header is an explicit drag handle; bottom corner resizes. Inputs never initiate gestures.
3. Store width/height and viewport alongside the existing v1 document; preserve old drafts and JSON imports. Links follow actual node dimensions.
4. Add duplicate and gesture-level undo/redo, with keyboard shortcuts excluded from form fields.
5. Browser acceptance must move two nodes before and after zoom, edit without moving, resize, check connections, undo/redo, pan, and reload persistence. Existing smoke test is insufficient.
Constraints: repository AGENTS requires server-only visual acceptance. Production redirects unauthenticated requests to /login. CI deploys only main; this branch must not merge or deploy without explicit authorization. Therefore a ready implementation alone is not completed visual acceptance.
Current checks: TypeScript noEmit (without next typegen/build) passed. Existing 13 unit tests passed; these do not validate canvas gestures. Browser script syntax and git diff checks passed. Browser acceptance and build have NOT run for this revision. Server unauthenticated HEAD returns 307 to /login?next=%2Fstudio%2Finfinite_canvas; in-app browser creation timed out. Waiting for user choice of permitted isolated acceptance environment before marking ready.
## Final layout and acceptance
User explicitly authorized temporary local runtime acceptance in this chat, overriding the server-only rule for this revision. A subsequent request requires the whole screen to be the dotted canvas. All reserved header/sidebar layout has been removed; compact floating chrome remains, and properties open on demand. Grid spacing adapts at low zoom to keep dots legible.
Browser acceptance ran in Chrome at 1600x1100 and 390x844. Passed: actual world-space movement of two nodes at two scales; editing without movement; resizing; gesture undo/redo; SVG connection start aligned to node geometry after resizing and panning; refresh document and viewport restoration; pointer-anchored Ctrl+wheel zoom; create/duplicate/delete/undo/redo; viewport equals window bounds and no horizontal mobile overflow. Production webpack compilation passed. Isolated-worktree dependency symlinks caused standalone tracing warnings (28 files outside tracing root), so deployment package completeness is not claimed. Local account-session request returns 404 because the shared account service runs in the full site; production authentication was not changed or validated here. Model generation remains explicitly unavailable.
Evidence screenshots are retained as intentional review deliverables under docs/evidence/canvas-rebuild/, not temporary output. Local server, dependency links and generated build/cache output are removed after acceptance. PR targets main for manual user merge; no deployment performed.
## Theme and unobstructed canvas follow-up
Added persistent light/dark themes covering the grid, cards, input fields, floating chrome and connection lines. Removed the left tool strip and the inspector. The tool strip is now a centered bottom dock; zoom/history floats at top right. Clicking empty canvas opens add actions; node ellipsis or right-click opens copy/delete/connect actions near the pointer. Escape or clicking elsewhere dismisses the menu. Node editors remain inline. Browser checks cover dock placement, theme persistence and contextual CRUD in addition to the existing gesture suite. TypeScript, 13 canvas-branch tests and webpack compilation passed; 19 S3-branch tests passed separately. User explicitly authorized merging PR #23 and #24 in this follow-up. Shared account-service and standalone tracing limitations remain as noted above.