Match native catalogue previews and fit vertical title line boxes
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import {CurvedCopy} from './NativeCurvedCopy';
|
||||
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';
|
||||
@@ -11,10 +12,6 @@ function TextElement({value,editable,onChange,onInvalid,onDirty,...props}:{value
|
||||
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,spacing=0}:{layout:Layout;slot:Slot;value:string;style:CSSProperties;spacing?:number}){
|
||||
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=.5+(index/Math.max(1,letters.length-1)-.5)*(1+spacing/Math.max(.1,textSize(layout,slot,value))),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[]>([]),[future,setFuture]=useState<Parameters[]>([]),[zoom,setZoom]=useState(1),[panel,setPanel]=useState<'layers'|'properties'|null>(null),[textEditing,setTextEditing]=useState<string|null>(null),[conflict,setConflict]=useState(false);
|
||||
const canvas=useRef<HTMLElement>(null),viewport=useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type {CSSProperties} from 'react';
|
||||
import type {Layout,Slot} from '../lib/layout-parameters';
|
||||
import {textSize} from '../lib/native-layout';
|
||||
export function CurvedCopy({layout,slot,value,style,spacing=0}:{layout:Layout;slot:Slot;value:string;style:CSSProperties;spacing?:number}){
|
||||
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=.5+(index/Math.max(1,letters.length-1)-.5)*(1+spacing/Math.max(.1,textSize(layout,slot,value))),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>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type {Layout} from '../lib/layout-parameters';
|
||||
import {paperColor,textColor,defaultText,textSize,slotPosition,imageShape,decorationKind} from '../lib/native-layout';
|
||||
import {CurvedCopy} from './NativeCurvedCopy';
|
||||
/** The catalogue uses the same native type, contours and ornament as the rendered page. */
|
||||
export function NativePreview({layout}:{layout:Layout}){
|
||||
return <div className={'native-layout '+layout.id} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:paperColor(layout.id)}} aria-label={layout.name} data-layout-id={layout.id}>
|
||||
{['image','decoration','text'].flatMap(kind=>layout.elements.filter(s=>s.kind===kind).map(s=>{
|
||||
const text=defaultText(layout,s),style={fontSize:textSize(layout,s,text)/layout.width*100+'cqw',color:textColor(layout.id,s)};
|
||||
return <div key={s.id} data-element={s.id} className={'native-element native-'+kind} style={{...slotPosition(layout,s),zIndex:kind==='text'?3:kind==='image'?1:2}}>
|
||||
{kind==='text'?(s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} style={style}/>:<div className={'native-copy direction-'+s.direction} style={style}>{text}</div>):kind==='image'?<div className="native-image-fill" style={imageShape(layout,s)}/>:<div className={'native-decoration decoration-'+decorationKind(s)} style={{color:'#83908a',backgroundColor:decorationKind(s)==='band'?'#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>;
|
||||
}))}
|
||||
</div>;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function slotPosition(layout:Layout,slot:Slot):CSSProperties {
|
||||
export function textSize(layout:Layout,slot:Slot,text:string):number {
|
||||
const [, ,w,h]=slot.box,lines=text.split('\n');
|
||||
const length=(s:string)=>Array.from(s).reduce((n,c)=>n+(/[\u3400-\u9fff]/.test(c)?1:/[A-Z]/.test(c)?.8:.6),0);
|
||||
if(slot.direction==='vertical')return Math.min(w*.9,h/Math.max(1,Array.from(text.replaceAll('\n','')).length)*.95);
|
||||
if(slot.direction==='vertical')return Math.min(w*.9,h/Math.max(1,Array.from(text.replaceAll('\n','')).length)*.88);
|
||||
if(slot.direction==='rotated')return Math.min(w*.85,h/Math.max(1,length(text)));
|
||||
if(slot.direction==='curved')return Math.min(2.2,w/Math.max(1,length(text)));
|
||||
return Math.min(h/(lines.length*1.15),w/Math.max(1,...lines.map(length)))*.92;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Head from 'next/head';
|
||||
import fixed from '../content/templates/layouts.json';
|
||||
import standard from '../content/standard-layout.json';
|
||||
import type {Layout} from '../lib/layout-parameters';
|
||||
import {nativeLayout,paperColor,textColor,defaultText,textSize,slotPosition,imageShape} from '../lib/native-layout';
|
||||
function Preview({layout}:{layout:Layout}){return <div className={'native-layout '+layout.id} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:paperColor(layout.id)}} aria-label={layout.name}>{layout.elements.map(s=><div key={s.id} className={'native-element native-'+s.kind} style={slotPosition(layout,s)}>{s.kind==='text'?<div className={'native-copy direction-'+s.direction} style={{fontSize:textSize(layout,s,defaultText(layout,s))/layout.width*100+'cqw',color:textColor(layout.id,s)}}>{defaultText(layout,s)}</div>:s.kind==='image'?<div className="native-image-fill" style={imageShape(layout,s)}/>:<div style={{borderTop:'1px solid',color:textColor(layout.id,s)}}/>}</div>)}</div>}
|
||||
import {nativeLayout} from '../lib/native-layout';
|
||||
import {NativePreview as Preview} from '../components/NativePreview';
|
||||
export default function StudioEntry(){return <main className="studio-library"><Head><title>Studio · 可视化排版 API 库</title></Head><nav className="library-nav"><a href="/">PROMPT / STUDIO</a><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档与工具箱</a><a href="/api/studio/designs?agent_mode=True">API 契约</a></nav><h1>用参数,编排画面。</h1><p className="library-intro">给 coding agent 文字、图片与创作意图。选择标准图文或固定构图,通过 HTTP 保存渲染描述,得到一个可持续更新的原生页面。</p><div className="library-heading"><h2>标准排版</h2><a href="/studio/api/layouts/standard-01">参数</a></div><div className="library-standard"><a href="/studio/layouts/standard-01"><Preview layout={standard}/></a><div><h2>一页,一件事。</h2><p>沿用 Studio 标题、正文与页脚的图文结构,增加可配置图片位。文字、颜色、图片和几何坐标共用一套参数,也可添加原生元素。</p><code>layoutId: standard-01</code><p><a href="/studio/layouts/standard-01">打开原生页面</a></p></div></div><div className="library-heading"><h2>固定排版 · 27 套</h2><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档</a></div><div className="library-grid">{fixed.map(l=><article className="library-item" key={l.id}><a href={'/studio/layouts/'+l.id}><Preview layout={nativeLayout(l)}/><h3>{l.name}</h3><p>{l.id.toUpperCase()}</p></a></article>)}</div></main>}
|
||||
|
||||
@@ -81,3 +81,6 @@
|
||||
.ref-21 .direction-rotated,.ref-24 .direction-angled,.ref-26 .native-text[data-element=T01] .native-copy,.ref-26 .native-text[data-element=T02] .native-copy,.ref-26 .native-text[data-element=T03] .native-copy{font-family:Georgia,"Songti SC",serif;font-weight:400;letter-spacing:-.055em}
|
||||
.studio-library{max-width:1440px;margin:auto;padding:48px 5vw 80px}.library-nav{display:flex;justify-content:space-between;font-size:13px}.studio-library h1{font-size:clamp(32px,5vw,64px);letter-spacing:-.05em;margin:64px 0 16px}.library-intro{max-width:640px;line-height:1.8;color:#69716e}.library-heading{margin:64px 0 24px;display:flex;align-items:baseline;gap:18px}.library-heading h2{margin:0;font-size:24px}.library-heading a{font-size:13px;text-decoration:underline}.library-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:36px 24px}.library-item{min-width:0}.library-item .native-layout{width:100%;pointer-events:none}.library-item h3{font-size:13px;font-weight:500;margin:14px 0 5px}.library-item p{font-size:11px;color:#7b837e;margin:0}.library-item>a:focus-visible{outline:2px solid #48786a;outline-offset:5px}.library-standard{display:grid;grid-template-columns:minmax(200px,320px) 1fr;gap:40px;align-items:center}.library-standard p{max-width:480px;line-height:1.9;color:#69716e}.library-standard code{font-size:13px}.library-standard a{text-decoration:underline}.library-item:hover .native-layout{filter:brightness(1.025)}
|
||||
@media(max-width:950px){.library-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:650px){.library-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:26px 16px}.library-standard{grid-template-columns:1fr}.library-standard .native-layout{max-width:280px}.studio-library{padding:28px 20px 60px}.library-nav{gap:12px}}
|
||||
|
||||
/* Keep the cross-axis line box inside narrow vertical title slots. */
|
||||
.native-copy.direction-vertical,.native-copy.direction-rotated{line-height:1}
|
||||
|
||||
@@ -12,13 +12,14 @@ const assert=require('node:assert/strict');
|
||||
const context=await browser.newContext({viewport:{width:1100,height:1000},deviceScaleFactor:1});
|
||||
await context.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});if(['data:','blob:'].includes(u.protocol))return route.continue();return route.abort()});
|
||||
const page=await context.newPage(),errors=[],images=[];
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
page.on('pageerror',error=>errors.push({url:page.url(),message:error.message}));
|
||||
page.on('response',async response=>{if(response.request().resourceType()==='image'){const url=new URL(response.url());images.push({path:url.pathname+url.search,status:response.status(),type:response.headers()['content-type'],bytes:(await response.body().catch(()=>Buffer.alloc(0))).length})}});
|
||||
const metrics=[];
|
||||
for(let i=1;i<=27;i++){
|
||||
const metrics=process.argv.includes('--after-layouts')?JSON.parse(await readFile(dir+'/metrics.json','utf8')).metrics:[];
|
||||
for(let i=1;i<=27&&!process.argv.includes('--after-layouts');i++){
|
||||
const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png'});
|
||||
const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));
|
||||
}
|
||||
if(!process.argv.includes('--after-layouts'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot');
|
||||
await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true});
|
||||
const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8'));
|
||||
for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png'});
|
||||
@@ -28,7 +29,8 @@ const assert=require('node:assert/strict');
|
||||
await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true});
|
||||
await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true});
|
||||
await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true});
|
||||
assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)}
|
||||
await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));
|
||||
assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(new URL(image.path,base).pathname==='/brand-mark.svg'){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)}
|
||||
await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close();
|
||||
console.log(JSON.stringify({pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)}));
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
Reference in New Issue
Block a user