import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent} from 'react'; import Head from 'next/head'; import type {Layout,Parameters,Slot} 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(null); 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}){ 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 })}
; } 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([]); const upload=useRef(null),slot=layout.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]);setParams(valid);setDirty(true);setStatus('')}catch(e){setStatus(e instanceof Error?e.message:'修改失败')}} function updateText(s:Slot,changes:Partial){patch({...params,texts:{...params.texts,[s.id]:{...params.texts[s.id],value:params.texts[s.id]?.value??defaultText(layout,s),...changes}}})} 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)throw Error(data.error||'保存失败'); setDesign(data.design);setDirty(false);setEditing(false);setSelected(null);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 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
{name} · Prompt
{['image','decoration','text'].flatMap(kind=>layout.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=kind==='decoration'&&d?.hidden; const position=slotPosition(layout,s),common={onContextMenu:(e:React.MouseEvent)=>{e.preventDefault();select(s)},onKeyDown:(e:KeyboardEvent)=>keySelect(e,s),onClick:()=>{if(editing)setSelected(s.id)},onDoubleClick:()=>select(s),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:`${textSize(layout,s,text)*(t?.scale||1)/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),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&&

点击文字直接改写;点击图形调整。也可以右键选择元素。

{design&&}
} {editing&&slot&&
{slot.role}
{slot.kind==='text'&&<>