diff --git a/apps/studio/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs index 95e52cf..f46db1e 100644 --- a/apps/studio/scripts/smoke.mjs +++ b/apps/studio/scripts/smoke.mjs @@ -2,14 +2,14 @@ import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises'; const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002'; 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(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:designId,name:'已保存的实际页面',layoutId:'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))}); +const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:designId,name:'已保存的实际页面',layoutId:'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,['.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 catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,27); 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(!/r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));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 '}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));} assert.equal(reads,0,'Default pages never load a saved design'); const headers={cookie:'session=smoke-test'}; -const saved=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('data-design-id="'+designId+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image')); +const saved=await fetch(base+'/studio/layouts/ref-01?id='+designId,{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="'+designId+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image')); assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404); assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404); const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid); diff --git a/apps/studio/src/components/LayoutWorkbench.tsx b/apps/studio/src/components/LayoutWorkbench.tsx index e44d999..3085f1f 100644 --- a/apps/studio/src/components/LayoutWorkbench.tsx +++ b/apps/studio/src/components/LayoutWorkbench.tsx @@ -11,9 +11,9 @@ function TextElement({value,editable,onChange,onInvalid,onDirty,...props}:{value useEffect(()=>{if(ref.current&&document.activeElement!==ref.current)ref.current.textContent=value},[value]); return
{const next=(e.currentTarget.innerText||'').replaceAll('\r','');if(Array.from(next).length>500||next.split('\n').length>8){e.currentTarget.textContent=value;onInvalid()}else if(next!==value)onChange(next)}}>{value}
; } -function CurvedCopy({layout,slot,value,style}:{layout:Layout;slot:Slot;value:string;style:CSSProperties}){ +function CurvedCopy({layout,slot,value,style,spacing=0}:{layout:Layout;slot:Slot;value:string;style:CSSProperties;spacing?:number}){ const letters=Array.from(value),tall=slot.box[3]>slot.box[2]; - return
{letters.map((letter,index)=>{const t=index/Math.max(1,letters.length-1),x=tall?90-75*Math.sin(Math.PI*t):4+92*t,y=tall?2+94*t:5+75*Math.sin(Math.PI*t),angle=tall?90+Math.cos(Math.PI*t)*18:Math.atan2(slot.box[3]*.75*Math.PI*Math.cos(Math.PI*t),slot.box[2]*.92)*180/Math.PI;return })}
; + return
{letters.map((letter,index)=>{const t=.5+(index/Math.max(1,letters.length-1)-.5)*(1+spacing/Math.max(.1,textSize(layout,slot,value))),x=tall?90-75*Math.sin(Math.PI*t):4+92*t,y=tall?2+94*t:5+75*Math.sin(Math.PI*t),angle=tall?90+Math.cos(Math.PI*t)*18:Math.atan2(slot.box[3]*.75*Math.PI*Math.cos(Math.PI*t),slot.box[2]*.92)*180/Math.PI;return })}
; } export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){ const [params,setParams]=useState(initialDesign?.parameters||emptyParameters()),[design,setDesign]=useState(initialDesign),[editing,setEditing]=useState(false),[selected,setSelected]=useState(null),[name,setName]=useState(initialDesign?.name||layout.name),[status,setStatus]=useState(''),[busy,setBusy]=useState(false),[dirty,setDirty]=useState(false),[history,setHistory]=useState([]),[future,setFuture]=useState([]),[zoom,setZoom]=useState(1),[panel,setPanel]=useState<'layers'|'properties'|null>(null),[textEditing,setTextEditing]=useState(null),[conflict,setConflict]=useState(false); @@ -31,10 +31,10 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){ function add(kind:string,shape?:'rectangle'|'ellipse'){const id='user-'+crypto.randomUUID();const added:AddedSlot={id,kind,role:kind==='text'?'新文字':kind==='image'?'新图片':shape==='ellipse'?'椭圆':'矩形',box:[layout.width*.15,layout.height*.15,layout.width*.3,layout.height*.16],direction:'horizontal',text:kind==='text'?'输入文字':null,reading:'custom',...(shape?{shape}:{})};patch({...params,added:[...(params.added||[]),added]});setSelected(id);setPanel('properties')} function reorder(direction:number){if(!slot||layer(slot).locked)return;const ordered=['image','decoration','text'].flatMap(kind=>elements.filter(e=>e.kind===kind)).sort((a,b)=>(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))-(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2)));const index=ordered.findIndex(e=>e.id===slot.id),target=index+direction;if(target<0||target>=ordered.length)return;[ordered[index],ordered[target]]=[ordered[target],ordered[index]];patch({...params,layers:{...params.layers,...Object.fromEntries(ordered.map((e,i)=>[e.id,{...layer(e),z:i+1}]))}})} function remove(){if(!slot||layer(slot).locked)return;updateLayer(slot,{hidden:true});setSelected(null)} - function duplicate(){if(!slot)return;const id='user-'+crypto.randomUUID(),l=layer(slot),group=slot.kind==='text'?'texts':slot.kind==='image'?'images':'decorations';patch({...params,added:[...(params.added||[]),{...slot,id,direction:'horizontal',reading:'custom',role:slot.role+' 副本',text:slot.kind==='text'?params.texts[slot.id]?.value??defaultText(layout,slot):null}],layers:{...params.layers,[id]:{...l,x:(l.x??slot.box[0])+2,y:(l.y??slot.box[1])+2,locked:false}},[group]:{...params[group],[id]:params[group][slot.id]|| (slot.kind==='text'?{value:defaultText(layout,slot)}:{})}});setSelected(id)} + function duplicate(){if(!slot||layer(slot).locked)return;const id='user-'+crypto.randomUUID(),l=layer(slot),group=slot.kind==='text'?'texts':slot.kind==='image'?'images':'decorations';patch({...params,added:[...(params.added||[]),{...slot,id,direction:'horizontal',reading:'custom',role:slot.role+' 副本',text:slot.kind==='text'?params.texts[slot.id]?.value??defaultText(layout,slot):null}],layers:{...params.layers,[id]:{...l,x:(l.x??slot.box[0])+2,y:(l.y??slot.box[1])+2,locked:false}},[group]:{...params[group],[id]:params[group][slot.id]|| (slot.kind==='text'?{value:defaultText(layout,slot)}:{})}});setSelected(id)} function pointer(e:PointerEvent,s:Slot,mode:'move'|'resize'|'rotate'='move'){if(!editing||busy||layer(s).locked||textEditing===s.id||e.button!==0)return;e.preventDefault();e.stopPropagation();setSelected(s.id);const start=params,l=layer(s),rect=canvas.current!.getBoundingClientRect(),x=e.clientX,y=e.clientY,bx=l.x??s.box[0],by=l.y??s.box[1],w=l.width??s.box[2],h=l.height??s.box[3];const cx=rect.left+(bx+w/2)/layout.width*rect.width,cy=rect.top+(by+h/2)/layout.height*rect.height;let next=start;const move=(event:globalThis.PointerEvent)=>{const dx=(event.clientX-x)/rect.width*layout.width,dy=(event.clientY-y)/rect.height*layout.height;let change:LayerValue=mode==='move'?{x:bx+dx,y:by+dy}:mode==='resize'?{width:Math.max(.1,w+dx),height:Math.max(.1,h+dy)}:{rotation:Math.max(-360,Math.min(360,(l.rotation||0)+(Math.atan2(event.clientY-cy,event.clientX-cx)-Math.atan2(y-cy,x-cx))*180/Math.PI))};next={...start,layers:{...start.layers,[s.id]:{...l,...change}}};setParams(next)};const end=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',end);window.removeEventListener('pointercancel',end);if(next!==start){setHistory(old=>[...old.slice(-39),start]);setFuture([]);setDirty(true)}};window.addEventListener('pointermove',move);window.addEventListener('pointerup',end);window.addEventListener('pointercancel',end)} useEffect(()=>{if(editing){fit()}},[editing]); - useEffect(()=>{function key(e:globalThis.KeyboardEvent){if(!editing)return;const typing=(e.target as HTMLElement).closest('input,textarea,select,[contenteditable="plaintext-only"]');if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='s'){e.preventDefault();if(!busy)void save();return}if(typing)return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape'){setSelected(null);setTextEditing(null)}else if(slot&&!layer(slot).locked&&['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();const step=e.shiftKey?10:1,l=layer(slot);updateLayer(slot,{x:(l.x??slot.box[0])+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:(l.y??slot.box[1])+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)}); + useEffect(()=>{function key(e:globalThis.KeyboardEvent){if(!editing||busy)return;const typing=(e.target as HTMLElement).closest('input,textarea,select,[contenteditable="plaintext-only"]');if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='s'){e.preventDefault();if(!busy)void save();return}if(typing)return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape'){setSelected(null);setTextEditing(null)}else if(slot&&!layer(slot).locked&&['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();const step=e.shiftKey?10:1,l=layer(slot);updateLayer(slot,{x:(l.x??slot.box[0])+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:(l.y??slot.box[1])+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)}); function select(s:Slot){if(busy)return;setEditing(true);setSelected(s.id)} function reset(s:Slot){const group=s.kind==='text'?'texts':s.kind==='image'?'images':'decorations',values={...params[group]};delete values[s.id];patch({...params,[group]:values})} function keySelect(e:KeyboardEvent,s:Slot){if(e.key==='ContextMenu'||(e.shiftKey&&e.key==='F10')){e.preventDefault();select(s)}if(e.key==='Escape')setSelected(null)} @@ -65,7 +65,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){ const position={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)},common={onContextMenu:(e:React.MouseEvent)=>{e.preventDefault();select(s)},onKeyDown:(e:KeyboardEvent)=>keySelect(e,s),onClick:()=>{if(editing)setSelected(s.id)},onPointerDown:(e:PointerEvent)=>pointer(e,s),onDoubleClick:()=>{select(s);if(!ls.locked&&kind==='text')setTextEditing(s.id)},tabIndex:editing?0:undefined,'data-element':s.id,'aria-label':s.role}; if(hidden)return null; return
- {kind==='text'&&s.direction==='curved'?:kind==='text'?setDirty(true)} value={text} onInvalid={()=>setStatus('文字最多 500 字、8 行')} onChange={value=>updateText(s,{value})} style={{fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily=== 'serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'}}/>: + {kind==='text'&&s.direction==='curved'?:kind==='text'?setDirty(true)} value={text} onInvalid={()=>setStatus('文字最多 500 字、8 行')} onChange={value=>updateText(s,{value})} style={{fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily=== 'serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'}}/>: kind==='image'?
{im?.assetId&&{s.role}}{editing&&!im?.assetId&&{s.role}}
:
{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=>)}{decorationKind(s)==='vertical-lines'&&<>}
} {editing&&selected===s.id&&!ls.locked&&<>}