From fef96a581935894c7cedd2cfb3c861feb0fbc631 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 27 Oct 2026 21:06:58 +0800 Subject: [PATCH] Generate workspace element IDs on HTTP origins without randomUUID --- apps/studio/src/components/LayoutWorkbench.tsx | 6 +++--- apps/studio/src/lib/native-layout.ts | 2 ++ apps/studio/tests/native-layout.test.mjs | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/studio/src/components/LayoutWorkbench.tsx b/apps/studio/src/components/LayoutWorkbench.tsx index 3085f1f..f0f8797 100644 --- a/apps/studio/src/components/LayoutWorkbench.tsx +++ b/apps/studio/src/components/LayoutWorkbench.tsx @@ -3,7 +3,7 @@ import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHoriz 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'; +import {decorationKind,defaultText,designUrl,emptyParameters,imageShape,newElementId,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}){ @@ -28,10 +28,10 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){ 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 add(kind:string,shape?:'rectangle'|'ellipse'){const id=newElementId();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||layer(slot).locked)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 duplicate(){if(!slot||layer(slot).locked)return;const id=newElementId(),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||busy)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)}); diff --git a/apps/studio/src/lib/native-layout.ts b/apps/studio/src/lib/native-layout.ts index e4c122e..92296c1 100644 --- a/apps/studio/src/lib/native-layout.ts +++ b/apps/studio/src/lib/native-layout.ts @@ -2,6 +2,8 @@ import type {CSSProperties} from 'react'; import type {Layout,Parameters,Slot} from './layout-parameters'; export type Design={id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string}; +// getRandomValues is available on the deployment's HTTP origin; randomUUID is not. +export const newElementId=()=> 'user-'+Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)),n=>n.toString(16).padStart(8,'0')).join(''); export const emptyParameters=():Parameters=>({texts:{},images:{},decorations:{}}); export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId}?id=${id}`; export function nativeLayout(layout:Layout):Layout { diff --git a/apps/studio/tests/native-layout.test.mjs b/apps/studio/tests/native-layout.test.mjs index 80d3412..924cb84 100644 --- a/apps/studio/tests/native-layout.test.mjs +++ b/apps/studio/tests/native-layout.test.mjs @@ -1,7 +1,7 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; -import {nativeLayout,defaultText,textSize,imageShape,designUrl} from '../src/lib/native-layout.ts'; +import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../src/lib/native-layout.ts'; const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url))); test('all 27 native pages are independent of source images and supply actual text',()=>{ for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000); @@ -14,3 +14,5 @@ test('geometric templates retain their distinct silhouettes without bitmap masks assert.notEqual(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath,imageShape(layouts[26],layouts[26].elements.find(s=>s.id==='I02')).clipPath); assert.equal(designUrl('ref-01','abc'),'/studio/layouts/ref-01?id=abc'); }); + +test('element IDs work on HTTP origins without secure-context randomUUID',()=>{const saved=Object.getOwnPropertyDescriptor(globalThis,'crypto');let calls=0;Object.defineProperty(globalThis,'crypto',{configurable:true,value:{getRandomValues(array){for(let i=0;i