Make layer ordering swap adjacent elements and apply shape corner radius
studio CI and deployment / verify-and-deploy (push) Successful in 5m2s

This commit is contained in:
2026-10-27 17:44:25 +08:00
parent 8ccc77dd79
commit 9f496688d2
@@ -1,5 +1,5 @@
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Check,Save} from 'lucide-react';
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react';
import Head from 'next/head';
import type {Layout,Parameters,Slot,LayerValue,AddedSlot} from '../lib/layout-parameters';
import {parameters} from '../lib/layout-parameters';
@@ -29,6 +29,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
function redo(){if(!future.length)return;setHistory(h=>[...h,params]);setParams(future[0]);setFuture(future.slice(1));setDirty(true)}
function fit(){if(!viewport.current)return;setZoom(Math.max(.1,Math.min(2,(viewport.current.clientWidth-96)/860,(viewport.current.clientHeight-96)/(860*layout.height/layout.width))))}
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 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)}
@@ -66,7 +67,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
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==='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:(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>}
<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></>}
</div>;
}))}
@@ -74,7 +75,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
</div>
{!editing&&<nav className="page-tools" aria-label="页面操作"><a href="/explore/8eb8c76500580f7a4b56ba3c">←</a><span className="page-layout-number">{layout.id.toUpperCase()}</span><button onClick={()=>setEditing(true)}>编辑页面</button>{dirty&&<span className="page-dirty">●</span>}</nav>}
{editing&&<aside className={'workbench-properties '+(panel==='properties'?'mobile-open':'')} inert={busy}><div className="panel-heading"><SlidersHorizontal size={16}/><strong>{slot?'元素属性':'页面设置'}</strong><span>{slot?.id||'Design'}</span></div>
{!slot?<><section><h3>页面</h3><label>纸张颜色<input type="color" value={params.paper||paperColor(layout.id)} onChange={e=>patch({...params,paper:e.target.value})}/></label><p className="property-hint">选择画布上的元素或左侧图层,调整它的设计属性。</p></section>{design&&<section><button onClick={()=>void save(true)}><Copy size={15}/>另存副本</button></section>}<section><h3>快捷操作</h3><p className="property-hint">⌘/Ctrl S 保存<br/>⌘/Ctrl Z 撤销<br/>Shift ⌘/Ctrl Z 重做<br/>Delete 隐藏所选图层<br/>Esc 取消选择</p></section></>:<><section><h3>{slot.role}<span>{slot.kind.toUpperCase()}</span></h3><div className="property-grid">{(['x','y','width','height','rotation','z'] as const).map((key,i)=><label key={key}>{['X','Y','宽','高','旋转 °','层级'][i]}<input aria-label={['X','Y','宽','高','旋转','层级'][i]} type="number" step="0.1" disabled={l.locked} value={Math.round((l[key]??(i<4?slot.box[i]:key==='z'?(slot.kind==='text'?3:slot.kind==='image'?1:2):0))*100)/100} onChange={e=>updateLayer(slot,{[key]:Number(e.target.value)})}/></label>)}</div><div className="property-actions"><button disabled={l.locked} onClick={()=>updateLayer(slot,{z:(l.z??(slot.kind==='text'?3:slot.kind==='image'?1:2))+1})}><ChevronUp size={14}/>上移</button><button disabled={l.locked} onClick={()=>updateLayer(slot,{z:(l.z??(slot.kind==='text'?3:slot.kind==='image'?1:2))-1})}><ChevronDown size={14}/>下移</button></div></section>
{!slot?<><section><h3>页面</h3><label>纸张颜色<input type="color" value={params.paper||paperColor(layout.id)} onChange={e=>patch({...params,paper:e.target.value})}/></label><p className="property-hint">选择画布上的元素或左侧图层,调整它的设计属性。</p></section>{design&&<section><button onClick={()=>void save(true)}><Copy size={15}/>另存副本</button></section>}<section><h3>快捷操作</h3><p className="property-hint">⌘/Ctrl S 保存<br/>⌘/Ctrl Z 撤销<br/>Shift ⌘/Ctrl Z 重做<br/>Delete 隐藏所选图层<br/>Esc 取消选择</p></section></>:<><section><h3>{slot.role}<span>{slot.kind.toUpperCase()}</span></h3><div className="property-grid">{(['x','y','width','height','rotation','z'] as const).map((key,i)=><label key={key}>{['X','Y','宽','高','旋转 °','层级'][i]}<input aria-label={['X','Y','宽','高','旋转','层级'][i]} type="number" step="0.1" disabled={l.locked} value={Math.round((l[key]??(i<4?slot.box[i]:key==='z'?(slot.kind==='text'?3:slot.kind==='image'?1:2):0))*100)/100} onChange={e=>updateLayer(slot,{[key]:Number(e.target.value)})}/></label>)}</div><div className="property-actions"><button disabled={l.locked} onClick={()=>reorder(1)}><ChevronUp size={14}/>上移</button><button disabled={l.locked} onClick={()=>reorder(-1)}><ChevronDown size={14}/>下移</button></div></section>
<fieldset disabled={l.locked}><section><h3>外观</h3><label>颜色<input type="color" value={selectedColor} onChange={e=>{const color=e.target.value;if(slot.kind==='text')updateText(slot,{color});else if(slot.kind==='image')patch({...params,images:{...params.images,[slot.id]:{...im,color}}});else patch({...params,decorations:{...params.decorations,[slot.id]:{...params.decorations[slot.id],color}}})}}/></label><div className="property-grid"><label>不透明度 %<input type="number" min="0" max="100" value={(l.opacity??1)*100} onChange={e=>updateLayer(slot,{opacity:Number(e.target.value)/100})}/></label><label>圆角<input type="number" min="0" max="5000" value={l.radius??0} onChange={e=>updateLayer(slot,{radius:Number(e.target.value)})}/></label></div></section>
{slot.kind==='text'&&<section><h3>文字</h3><textarea aria-label="文字内容" rows={4} value={t?.value??defaultText(layout,slot)} onChange={e=>updateText(slot,{value:e.target.value})}/><label>字体<select value={t?.fontFamily||''} onChange={e=>updateText(slot,{fontFamily:e.target.value as 'sans'|'serif'|'mono'})}><option value="" disabled>模板默认</option><option value="sans">无衬线</option><option value="serif">衬线</option><option value="mono">等宽</option></select></label><div className="property-grid">{(['fontSize','fontWeight','lineHeight','letterSpacing'] as const).map((key,i)=><label key={key}>{['字号','字重','行距','字距'][i]}<input type="number" step={key==='lineHeight'?.1:key==='fontWeight'?100:.1} value={Math.round((t?.[key]??[textSize(layout,slot,t?.value??defaultText(layout,slot))*(t?.scale||1),500,1.15,0][i])*100)/100} onChange={e=>updateText(slot,{[key]:Number(e.target.value)})}/></label>)}</div><label>对齐<select value={t?.align||'start'} onChange={e=>updateText(slot,{align:e.target.value as 'start'|'middle'|'end'})}><option value="start">左对齐</option><option value="middle">居中</option><option value="end">右对齐</option></select></label></section>}
{slot.kind==='image'&&<section><h3>图片</h3><button onClick={()=>upload.current?.click()}><ImagePlus size={15}/>上传 / 替换图片</button><label>适应<select value={im?.fit||'cover'} onChange={e=>patch({...params,images:{...params.images,[slot.id]:{...im,fit:e.target.value as 'cover'|'contain'}}})}><option value="cover">裁切填满</option><option value="contain">完整显示</option></select></label>{(['cropX','cropY','zoom'] as const).map((key,i)=><label key={key}>{['水平裁切','垂直裁切','图片缩放'][i]}<input type="range" min={i===2?1:0} max={i===2?5:100} step={i===2?.05:1} value={im?.[key]??(i===2?1:50)} onChange={e=>patch({...params,images:{...params.images,[slot.id]:{...im,[key]:Number(e.target.value)}}})}/></label>)}</section>}</fieldset>