Files
probably-rendering-one-more…/apps/studio/src/components/LayoutWorkbench.tsx
T
codex-bot 3ffe457be4
studio CI and deployment / verify-and-deploy (push) Failing after 2m55s
Replace layout workbenches with native editable pages and server-loaded designs
2026-10-27 16:46:27 +08:00

73 lines
12 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} 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<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[]>([]);
const upload=useRef<HTMLInputElement>(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<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 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 <main className={'native-layout-page'+(editing?' is-editing':'')}>
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
<article 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)}}>
{['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 <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:`${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} onDirty={()=>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'?<div className="native-image-fill" style={{...imageShape(layout,s),...(im?.color?{backgroundColor:im.color}:{})}}>{im?.assetId&&<img src={'/api/media/'+im.assetId} alt={s.role} draggable={false} style={{objectFit:im.fit||'cover'}}/>}{editing&&!im?.assetId&&<span className="native-image-label">{s.role}</span>}</div>:
<div className={'native-decoration decoration-'+decorationKind(s)} style={{color:d?.color||'#83908a',backgroundColor: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>;
}))}
</article>
<nav className="page-tools" aria-label="页面操作">
<a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回排版目录">←</a>
<span className="page-layout-number">{layout.id.toUpperCase()}</span>
{editing?<><button onClick={()=>{setEditing(false);setSelected(null)}}>查看页面</button><button disabled={!history.length||busy} onClick={()=>{setParams(history[history.length-1]);setHistory(history.slice(0,-1));setDirty(true)}}>撤销</button><button className="page-save" disabled={busy} onClick={()=>void save()}>{busy?'处理中…':design?'保存修改':'保存页面'}</button></>:<button onClick={()=>setEditing(true)}>编辑页面</button>}
{dirty&&<span className="page-dirty" aria-label="尚未保存">●</span>}
</nav>
{editing&&<div className="page-edit-info" inert={busy}><p>点击文字直接改写;点击图形调整。也可以右键选择元素。</p><label>页面名称<input value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/></label><div className="page-edit-options"><label>纸色<input type="color" aria-label="纸色" value={params.paper||paperColor(layout.id)} onChange={e=>patch({...params,paper:e.target.value})}/></label><label>选择元素<select aria-label="选择元素" value={selected||''} onChange={e=>setSelected(e.target.value||null)}><option value="">选择元素…</option>{layout.elements.map(s=><option value={s.id} key={s.id}>{s.role}</option>)}</select></label>{design&&<button disabled={busy} onClick={()=>void save(true)}>另存副本</button>}</div></div>}
{editing&&slot&&<div className="element-popover" inert={busy} role="dialog" aria-label={'编辑'+slot.role}>
<div className="element-popover-heading"><strong>{slot.role}</strong><button aria-label="关闭元素设置" onClick={()=>setSelected(null)}>×</button></div>
<label>颜色<input type="color" aria-label={slot.role+'颜色'} 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]:{...params.images[slot.id],color}}});else patch({...params,decorations:{...params.decorations,[slot.id]:{...params.decorations[slot.id],color}}})}}/></label>
{slot.kind==='text'&&<><label>字号<input type="range" min="0.25" max="2" step="0.05" value={params.texts[slot.id]?.scale||1} onChange={e=>updateText(slot,{scale:Number(e.target.value)})}/></label><label>对齐<select value={params.texts[slot.id]?.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><label className="element-text-edit">文字<textarea rows={3} value={params.texts[slot.id]?.value??defaultText(layout,slot)} onChange={e=>updateText(slot,{value:e.target.value})}/></label></>}
{slot.kind==='image'&&<><button disabled={busy} onClick={()=>upload.current?.click()}>替换图片</button>{params.images[slot.id]?.assetId&&<><label>图片适应<select value={params.images[slot.id]?.fit||'cover'} onChange={e=>patch({...params,images:{...params.images,[slot.id]:{...params.images[slot.id],fit:e.target.value as 'cover'|'contain'}}})}><option value="cover">填满</option><option value="contain">完整显示</option></select></label><button onClick={()=>{const image={...params.images[slot.id]};delete image.assetId;patch({...params,images:{...params.images,[slot.id]:image}})}}>移除图片</button></>}</>}
{slot.kind==='decoration'&&<label>隐藏<input type="checkbox" checked={params.decorations[slot.id]?.hidden||false} onChange={e=>patch({...params,decorations:{...params.decorations,[slot.id]:{...params.decorations[slot.id],hidden:e.target.checked}}})}/></label>}
<button onClick={()=>reset(slot)}>恢复默认</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>;
}