Expose Studio cover rendering API and add three source-based layout tools
studio CI and deployment / verify-and-deploy (push) Successful in 3m51s

This commit is contained in:
2026-10-26 20:26:17 +08:00
parent 7f37aa4c93
commit 4ba7eb7db7
12 changed files with 455 additions and 21 deletions
@@ -0,0 +1,13 @@
import {useState} from 'react';
import Head from 'next/head';
import {SourceCover,selections,type CoverValues} from './SourceCover';
import {tutorialPost} from '../content/tool-links';
export function LayoutSelection(){
const [active,setActive]=useState(0),[values,setValues]=useState<CoverValues[]>(selections.map(l=>({fields:Object.fromEntries(Object.entries(l.fields).filter(([,v])=>v!==undefined)) as Record<string,string>,color:'#83918a',accent:'#c3cec5'}))),[guides,setGuides]=useState(false),[status,setStatus]=useState('');
const layout=selections[active],current=values[active];
function update(patch:Partial<CoverValues>){setValues(old=>old.map((v,i)=>i===active?{...v,...patch}:v));}
async function download(format:'svg'|'json'){
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
}
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← waterfall-wiki 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位蒙版与装饰来自教程的分层资产;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始装饰为低分辨率图层。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={layout.referenceUrl} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
}
@@ -0,0 +1,21 @@
import {useId} from 'react';
import selections from '../content/templates/selection.json';
export type SourceLayout=typeof selections[number];
export type CoverValues={fields:Record<string,string>;color:string;accent:string};
export {selections};
export function SourceCover({layout,values,guides=false}:{layout:SourceLayout;values:CoverValues;guides?:boolean}){
const uid=useId().replace(/:/g,''),W=layout.width*10,H=layout.height*10;
return <svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${W} ${H}`} width={W} height={H} role="img" aria-label={layout.id+' '+layout.name} data-layout-id={layout.id} style={{display:'block',width:'100%',height:'auto'}}>
<title>{layout.name} · 封面试版</title><desc>文字为可编辑占位,图位使用原始形状蒙版,装饰沿用原图分层。来源 {layout.tutorialUrl}</desc><rect width={W} height={H} fill="#f4f2eb"/>
{['image','decoration','text'].flatMap(kind=>layout.slots.filter(s=>s.kind===kind).map(s=>{
const [x,y,w,h]=s.box.map(n=>n*10),raster='raster' in s?s.raster:undefined;
if(kind==='image')return <g data-slot={s.id} key={s.id}><defs><mask id={uid+s.id} maskUnits="userSpaceOnUse" x={x} y={y} width={w} height={h} style={{maskType:'alpha'}}><image href={raster} x={x} y={y} width={w} height={h}/></mask></defs><rect x={x} y={y} width={w} height={h} fill={s.id==='I01'?values.color:values.accent} mask={`url(#${uid+s.id})`}/></g>;
if(kind==='decoration')return <image data-slot={s.id} key={s.id} href={raster} x={x} y={y} width={w} height={h}/>;
const text=values.fields[s.id]||'',lines=text.split('\n').slice(0,4),length=(str:string)=>Array.from(str).reduce((sum,c)=>sum+(/[\u3400-\u9fff]/.test(c)?1:.6),0),size=Math.min(h/(lines.length*1.25),w/Math.max(1,...lines.map(length))*.94);
if(s.direction==='vertical'){const letters=Array.from(text.replace(/\n/g,'')),font=Math.min(w*.88,h/Math.max(1,letters.length));return <g data-slot={s.id} key={s.id} fill="#26362f" fontFamily="'PingFang SC',sans-serif">{letters.map((c,i)=><text key={i} x={x+w/2} y={y+font*(i+.85)} textAnchor="middle" fontSize={font}>{c}</text>)}</g>}
if(s.direction==='curved')return <g data-slot={s.id} key={s.id}><defs><path id={uid+s.id} d={`M${x} ${y} Q${x+w/2} ${y+h*1.8} ${x+w} ${y}`}/></defs><text fill="#26362f" fontFamily="Arial,sans-serif" fontSize={Math.min(36,w/Math.max(1,text.length*.6))} letterSpacing="1"><textPath href={'#'+uid+s.id} startOffset="50%" textAnchor="middle">{text}</textPath></text></g>;
return <g data-slot={s.id} key={s.id} fill="#26362f" fontFamily={h>100?"Georgia,'PingFang SC',serif":"Arial,'PingFang SC',sans-serif"}>{lines.map((line,i)=><text key={i} x={x} y={y+size*(.88+i*1.25)} fontSize={size}>{line}</text>)}</g>;
}))}
{guides&&<g aria-hidden="true" fontFamily="Arial,sans-serif">{layout.slots.map(s=>{const[x,y,w,h]=s.box.map(n=>n*10),color=({text:'#2c64d8',image:'#d36b30',decoration:'#08875d'} as Record<string,string>)[s.kind];return <g key={s.id}><rect x={x} y={y} width={w} height={h} stroke={color} strokeWidth="2" fill="none"/><rect x={x} y={y} width="66" height="29" fill={color}/><text x={x+4} y={y+22} fontSize="24" fill="white">{s.id}</text></g>})}</g>}
</svg>;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import {useState} from 'react';
import Head from 'next/head';
import Link from 'next/link';
import {tutorialPost} from '../content/tool-links';
import {ideaMarkdown,type Idea,type Slide} from '../content/ideas';
import {Ref17Cover} from './Ref17Cover';
export function TemplateTrial({idea}:{idea:Idea}){
@@ -12,7 +12,7 @@ export function TemplateTrial({idea}:{idea:Idea}){
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
<header className="trial-nav"><Link href="/">← 我的创作</Link><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
<header className="trial-nav"><a href={tutorialPost}>← waterfall-wiki 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>