90 lines
24 KiB
TypeScript
90 lines
24 KiB
TypeScript
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>;
|
||
}
|