Verify restored native workspace geometry and support curved type styling
studio CI and deployment / verify-and-deploy (push) Successful in 5m40s
studio CI and deployment / verify-and-deploy (push) Successful in 5m40s
This commit is contained in:
@@ -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(!/<svg|<img|data:image|trial-controls/.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+'"'));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('<'));}
|
||||
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);
|
||||
|
||||
@@ -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 <div {...props} ref={ref} contentEditable={editable?'plaintext-only':false} suppressContentEditableWarning spellCheck={false} role={editable?'textbox':undefined} aria-multiline={editable||undefined} onInput={onDirty} onBlur={e=>{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}</div>;
|
||||
}
|
||||
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 <div className="native-copy native-arc" aria-label={value} style={style}>{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 <span key={index} aria-hidden="true" style={{left:x+'%',top:y+'%',transform:`translate(-50%,-50%) rotate(${angle}deg)`}}>{letter===' '?'\u00a0':letter}</span>})}</div>;
|
||||
return <div className="native-copy native-arc" aria-label={value} style={style}>{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 <span key={index} aria-hidden="true" style={{left:x+'%',top:y+'%',transform:`translate(-50%,-50%) rotate(${angle}deg)`}}>{letter===' '?'\u00a0':letter}</span>})}</div>;
|
||||
}
|
||||
export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
|
||||
const [params,setParams]=useState<Parameters>(initialDesign?.parameters||emptyParameters()),[design,setDesign]=useState(initialDesign),[editing,setEditing]=useState(false),[selected,setSelected]=useState<string|null>(null),[name,setName]=useState(initialDesign?.name||layout.name),[status,setStatus]=useState(''),[busy,setBusy]=useState(false),[dirty,setDirty]=useState(false),[history,setHistory]=useState<Parameters[]>([]),[future,setFuture]=useState<Parameters[]>([]),[zoom,setZoom]=useState(1),[panel,setPanel]=useState<'layers'|'properties'|null>(null),[textEditing,setTextEditing]=useState<string|null>(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 <div key={s.id} {...common} className={`native-element native-${kind}${selected===s.id&&editing?' is-selected':''}`} style={position}>
|
||||
{kind==='text'&&s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} style={{fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s)}}/>:kind==='text'?<TextElement className={'native-copy direction-'+s.direction} editable={editing&&textEditing===s.id&&!ls.locked} onDirty={()=>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'?<CurvedCopy layout={layout} slot={s} value={text} spacing={t?.letterSpacing} style={{fontFamily:t?.fontFamily==='serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s)}}/>:kind==='text'?<TextElement className={'native-copy direction-'+s.direction} editable={editing&&textEditing===s.id&&!ls.locked} onDirty={()=>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'?<div className="native-image-fill" style={{...imageShape(layout,s),...(im?.color?{backgroundColor:im.color}:{}),...(ls.radius!==undefined?{borderRadius:`${ls.radius/layout.width*100}cqw`}:{})}}>{im?.assetId&&<img src={'/api/media/'+im.assetId} alt={s.role} draggable={false} style={{objectFit:im.fit||'cover',objectPosition:`${im.cropX??50}% ${im.cropY??50}%`,transform:`scale(${im.zoom??1})`}}/>}{editing&&!im?.assetId&&<span className="native-image-label">{s.role}</span>}</div>:
|
||||
<div className={'native-decoration decoration-'+((s as AddedSlot).shape?'solid':decorationKind(s))} style={{borderRadius:ls.radius!==undefined?`${ls.radius/layout.width*100}cqw`:(s as AddedSlot).shape==='ellipse'?'50%':undefined,color:d?.color||'#83908a',backgroundColor:(s as AddedSlot).shape?d?.color||'#83908a':decorationKind(s)==='band'?d?.color||'#dce1d8':undefined}}>{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=><i key={i}/>)}{decorationKind(s)==='vertical-lines'&&<><i/><i/><i/></>}</div>}
|
||||
{editing&&selected===s.id&&!ls.locked&&<><button className="resize-handle" aria-label="拖动调整尺寸" onPointerDown={e=>pointer(e,s,'resize')}/><button className="rotate-handle" aria-label="拖动旋转" onPointerDown={e=>pointer(e,s,'rotate')}>↻</button></>}
|
||||
|
||||
Reference in New Issue
Block a user