73 lines
12 KiB
TypeScript
73 lines
12 KiB
TypeScript
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>;
|
||
}
|