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(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([]),[future,setFuture]=useState([]),[zoom,setZoom]=useState(1),[panel,setPanel]=useState<'layers'|'properties'|null>(null),[textEditing,setTextEditing]=useState(null),[conflict,setConflict]=useState(false); const canvas=useRef(null),viewport=useRef(null); const elements=[...layout.elements,...(params.added||[])]; const upload=useRef(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){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
{name} · Prompt {editing&&
PSTUDIO{setName(e.target.value);setDirty(true)}}/>{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}
} {editing&&}
{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}> {editing&&
{layout.name}{layout.width} × {layout.height}
}
{['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
{kind==='text'&&s.direction==='curved'?:kind==='text'?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'?
{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&&selected===s.id&&!ls.locked&&<>}
; }))}
{!editing&&} {editing&&