Files
probably-rendering-one-more…/apps/studio/src/components/LayoutWorkbench.tsx
T
codex-bot 9f496688d2
studio CI and deployment / verify-and-deploy (push) Successful in 5m2s
Make layer ordering swap adjacent elements and apply shape corner radius
2026-10-27 17:44:25 +08:00

90 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,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';
import {decorationKind,defaultText,designUrl,emptyParameters,imageShape,paperColor,slotPosition,textColor,textSize,type Design} from '../lib/native-layout';
export type LayoutPageProps={layout:Layout;design:Design|null};
function TextElement({value,editable,onChange,onInvalid,onDirty,...props}:{value:string;editable:boolean;onChange:(text:string)=>void;onInvalid:()=>void;onDirty:()=>void;className:string;style:CSSProperties}){
const ref=useRef<HTMLDivElement>(null);
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}){
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>;
}
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);
const canvas=useRef<HTMLElement>(null),viewport=useRef<HTMLDivElement>(null);
const elements=[...layout.elements,...(params.added||[])];
const upload=useRef<HTMLInputElement>(null),slot=elements.find(s=>s.id===selected);
useEffect(()=>{const warn=(event:BeforeUnloadEvent)=>{if(dirty){event.preventDefault();event.returnValue=''}};window.addEventListener('beforeunload',warn);return()=>window.removeEventListener('beforeunload',warn)},[dirty]);
function patch(next:Parameters){try{const valid=parameters(layout,next);setHistory(old=>[...old.slice(-39),params]);setFuture([]);setParams(valid);setDirty(true);setStatus('')}catch(e){setStatus(e instanceof Error?e.message:'修改失败')}}
function updateText(s:Slot,changes:Partial<Parameters['texts'][string]>){patch({...params,texts:{...params.texts,[s.id]:{...params.texts[s.id],value:params.texts[s.id]?.value??defaultText(layout,s),...changes}}})}
function layer(s:Slot):LayerValue{return params.layers?.[s.id]||{}}
function updateLayer(s:Slot,changes:LayerValue){patch({...params,layers:{...params.layers,[s.id]:{...layer(s),...changes}}})}
function undo(){if(!history.length)return;setFuture(f=>[params,...f]);setParams(history[history.length-1]);setHistory(history.slice(0,-1));setDirty(true)}
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)}
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)});
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)}
async function save(copy=false){
setBusy(true);try{
const valid=parameters(layout,params),updating=design&&!copy;
const response=await fetch('/api/studio/designs'+(updating?'/'+design.id:''),{method:updating?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(updating?{name,parameters:valid,revision:design.revision}:{name,layoutId:layout.id,parameters:valid})});
const data=await response.json();if(!response.ok){if(response.status===409)setConflict(true);throw Error(data.error||'保存失败')};setConflict(false);
setDesign(data.design);setDirty(false);setStatus('已保存,当前网址可直接打开此页面');
window.history.replaceState(window.history.state,'',designUrl(layout.id,data.design.id));
}catch(e){setStatus(e instanceof Error?e.message:'保存失败')}finally{setBusy(false)}
}
async function uploadImage(file:File|undefined){
if(!file||!slot||slot.kind!=='image')return;const target=slot.id;setBusy(true);setStatus('正在上传图片…');
try{const body=new FormData();body.append('assetOnly','true');body.append('kind','image');body.append('files',file);const response=await fetch('/api/upload',{method:'POST',body});const data=await response.json();if(!response.ok||!data.assets?.[0])throw Error(data.error||'上传失败');patch({...params,images:{...params.images,[target]:{...params.images[target],assetId:data.assets[0]}}});setStatus('图片已替换,保存后生效于此网址')}catch(e){setStatus(e instanceof Error?e.message:'上传失败')}finally{setBusy(false);if(upload.current)upload.current.value=''}
}
const l=slot?layer(slot):{},t=slot?params.texts[slot.id]:undefined,im=slot?params.images[slot.id]:undefined;
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><span className="studio-mark">P<span>STUDIO</span></span><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}
<article ref={canvas} className={'native-layout '+layout.id} inert={busy} aria-label={layout.name} data-layout-id={layout.id} data-design-id={design?.id||undefined} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:params.paper||paperColor(layout.id),...(editing?{width:860*zoom,minWidth:860*zoom,marginBottom:40}:{} )}}>
{['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{
const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],text=t?.value??defaultText(layout,s),hidden=layer(s).hidden||(kind==='decoration'&&d?.hidden),ls=layer(s);
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==='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></>}
</div>;
}))}
</article>
</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={()=>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>
<section><div className="property-actions"><button disabled={l.locked} onClick={duplicate}><Copy size={14}/>复制</button><button disabled={l.locked} onClick={remove}><Trash2 size={14}/>删除</button></div><button disabled={l.locked} onClick={()=>reset(slot)}>恢复内容默认值</button></section></>}
</aside>}
{editing&&<nav className="workbench-bottom" aria-label="画布工具"><button className={panel==='layers'?'active':''} title="图层面板" onClick={()=>setPanel(panel==='layers'?null:'layers')}><Layers size={17}/></button><MousePointer2 size={18}/><span className="toolbar-divider"/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.1,z-.1))}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(3,z+.1))}><Plus size={16}/></button><button title="适合窗口" aria-label="适合窗口" onClick={fit}><Scan size={17}/></button><span className="toolbar-divider"/><button className={panel==='properties'?'active':''} title="属性面板" onClick={()=>setPanel(panel==='properties'?null:'properties')}><SlidersHorizontal size={17}/></button></nav>}
{conflict&&<div className="conflict-notice" role="alert"><strong>此页面已有更新</strong><p>本地修改已保留。可另存副本,或重新加载服务器版本。</p><button disabled={busy} onClick={()=>void save(true)}>另存副本</button><button onClick={()=>{if(window.confirm('重新加载将丢弃本地未保存修改,继续?')){setDirty(false);window.location.reload()}}}>重新加载</button></div>}
<input ref={upload} type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" hidden onChange={e=>void uploadImage(e.target.files?.[0])}/>
{status&&<p className="page-status" role="status">{status}</p>}
</main>;
}