- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Canvely — Spring Menu 1.0.0, shown as text in the browser.
import React, {useEffect,useLayoutEffect,useRef,useState} from 'react';
import {createRoot} from 'react-dom/client';
import seed from '../public/data/document.json';
import './styles.css';
type Kind='frame'|'group'|'rect'|'ellipse'|'text';
type Layer={id:string;type:Kind;name:string;visible:boolean;locked:boolean;x?:number;y?:number;width?:number;height?:number;rotation?:number;fill?:string;opacity?:number;corner_radius?:number;stroke?:{color:string;width:number}|null;text?:string;font_size?:number;font_weight?:number;line_height?:number;align?:'left'|'center'|'right';children?:Layer[]};
type Doc={product:string;workspace:string;file:string;fictional_notice:string;children:Layer[]};
type Box={x:number;y:number;width:number;height:number};
type View={z:number;tx:number;ty:number};
type Drag={kind:'move'|'resize'|'rotate'|'marquee'|'pan'|'create';start:{x:number;y:number};startScreen:{x:number;y:number};before:Doc;ids:string[];box?:Box;handle?:string;angle?:number;frame?:string|null;tool?:Kind;shift?:boolean;alt?:boolean;current?:{x:number;y:number}};
const initial=seed as Doc, storage='canvely-spring-menu-v1';
const copy=<T,>(v:T):T=>structuredClone(v);
const clamp=(v:number,a:number,b:number)=>Math.min(b,Math.max(a,v));
const round=(n:number)=>Math.round(n*10)/10;
const children=(l:Layer|Doc)=>l.children||[];
function find(doc:Doc,id:string):{layer:Layer;parent:Layer|Doc;frame:Layer|null;path:Layer[]}|null{function walk(parent:Layer|Doc,path:Layer[],frame:Layer|null):any{for(const l of children(parent)){if(l.id===id)return {layer:l,parent,frame:l.type==='frame'?l:frame,path:[...path,l]};const r=walk(l,[...path,l],l.type==='frame'?l:frame);if(r)return r}return null}return walk(doc,[],null)}
function origin(doc:Doc,id:string){const f=find(doc,id);return f?.frame&&f.layer.type!=='frame'?{x:f.frame.x||0,y:f.frame.y||0}:{x:0,y:0}}
function localBounds(l:Layer):Box{if(l.type==='group'){const bs=(l.children||[]).map(localBounds);if(!bs.length)return {x:0,y:0,width:1,height:1};const x=Math.min(...bs.map(b=>b.x)),y=Math.min(...bs.map(b=>b.y)),r=Math.max(...bs.map(b=>b.x+b.width)),bt=Math.max(...bs.map(b=>b.y+b.height));return {x,y,width:r-x,height:bt-y}}const x=l.x||0,y=l.y||0,w=l.width||1,h=l.height||1,a=(l.rotation||0)*Math.PI/180;const ex=Math.abs(w*Math.cos(a))/2+Math.abs(h*Math.sin(a))/2,ey=Math.abs(w*Math.sin(a))/2+Math.abs(h*Math.cos(a))/2;return {x:x+w/2-ex,y:y+h/2-ey,width:ex*2,height:ey*2}}
function box(doc:Doc,id:string):Box{const f=find(doc,id)!;const b=localBounds(f.layer),o=origin(doc,id);return {...b,x:b.x+o.x,y:b.y+o.y}}
function union(bs:Box[]):Box{if(!bs.length)return {x:0,y:0,width:1,height:1};const x=Math.min(...bs.map(b=>b.x)),y=Math.min(...bs.map(b=>b.y)),r=Math.max(...bs.map(b=>b.x+b.width)),bt=Math.max(...bs.map(b=>b.y+b.height));return {x,y,width:r-x,height:bt-y}}
function intersects(a:Box,b:Box){return a.x<=b.x+b.width&&a.x+a.width>=b.x&&a.y<=b.y+b.height&&a.y+a.height>=b.y}
function inside(p:{x:number;y:number},b:Box){return p.x>=b.x&&p.x<=b.x+b.width&&p.y>=b.y&&p.y<=b.y+b.height}
function hit(l:Layer,p:{x:number;y:number},o:{x:number;y:number}):boolean{if(l.type==='group')return (l.children||[]).some(c=>c.visible&&hit(c,p,o));const w=l.width||1,h=l.height||1,cx=o.x+(l.x||0)+w/2,cy=o.y+(l.y||0)+h/2,a=-(l.rotation||0)*Math.PI/180,dx=p.x-cx,dy=p.y-cy,xx=dx*Math.cos(a)-dy*Math.sin(a),yy=dx*Math.sin(a)+dy*Math.cos(a);return l.type==='ellipse'?(xx/(w/2))**2+(yy/(h/2))**2<=1:Math.abs(xx)<=w/2&&Math.abs(yy)<=h/2}
function hitLayer(doc:Doc,p:{x:number;y:number}){function scan(parent:Layer|Doc,fr:Layer|null,anc:Layer[]):Layer[]|null{const arr=children(parent);for(let i=arr.length-1;i>=0;i--){const l=arr[i];if(!l.visible||l.locked)continue;if(l.type==='frame'){if(!inside(p,box(doc,l.id)))continue;const r=scan(l,l,[]);if(r)return r;continue}const o=fr?{x:fr.x||0,y:fr.y||0}:{x:0,y:0};if(l.type==='group'){const r=scan(l,fr,[...anc,l]);if(r)return r}else if(hit(l,p,o))return [...anc,l]}return null}return scan(doc,null,[])}
function frameAt(doc:Doc,p:{x:number;y:number}){return doc.children.slice().reverse().find(l=>l.type==='frame'&&l.visible&&inside(p,box(doc,l.id)))||null}
function translate(l:Layer,dx:number,dy:number){if(l.type==='group')(l.children||[]).forEach(c=>translate(c,dx,dy));else {l.x=round((l.x||0)+dx);l.y=round((l.y||0)+dy)}}
function scaleLayer(l:Layer,old:Box,now:Box){const sx=now.width/Math.max(old.width,1),sy=now.height/Math.max(old.height,1);function apply(q:Layer){if(q.type==='group'){(q.children||[]).forEach(apply);return}q.x=round(now.x+((q.x||0)-old.x)*sx);q.y=round(now.y+((q.y||0)-old.y)*sy);q.width=round(Math.max(1,(q.width||1)*sx));q.height=round(Math.max(1,(q.height||1)*sy))}apply(l)}
function icon(k:string){const common={width:16,height:16,viewBox:'0 0 16 16',fill:'none',stroke:'currentColor',strokeWidth:1.5,strokeLinecap:'round' as const,strokeLinejoin:'round' as const};const paths:Record<string,React.ReactNode>={move:<><path d="M3 1.8v11.7l3.1-3.2 2.4 4 1.7-.9-2.4-4.1h4.4z"/></>,rect:<rect x="2.4" y="3" width="11.2" height="10" rx="1"/>,ellipse:<ellipse cx="8" cy="8" rx="5.6" ry="5"/>,text:<><path d="M2.4 3.5h11.2M8 3.5v9.5M5.5 13h5"/></>,undo:<path d="M5.5 4 2 7.3l3.5 3.2M2.2 7.3h7.5a3.7 3.7 0 0 1 0 7"/>,redo:<path d="M10.5 4 14 7.3l-3.5 3.2M13.8 7.3H6.3a3.7 3.7 0 0 0 0 7"/>,eye:<><path d="M1.3 8s2.4-4 6.7-4 6.7 4 6.7 4-2.4 4-6.7 4S1.3 8 1.3 8Z"/><circle cx="8" cy="8" r="1.8"/></>,hidden:<><path d="M2 2l12 12M4.5 5C2.4 6.3 1.3 8 1.3 8s2.4 4 6.7 4c1 0 2-.2 2.8-.5M6.3 4.2c.5-.1 1.1-.2 1.7-.2 4.3 0 6.7 4 6.7 4s-.8 1.4-2.3 2.5"/></>,lock:<><rect x="3.5" y="7" width="9" height="7" rx="1"/><path d="M5.5 7V4.8a2.5 2.5 0 0 1 5 0V7"/></>,unlock:<><rect x="3.5" y="7" width="9" height="7" rx="1"/><path d="M5.5 7V4.8a2.5 2.5 0 0 1 4.8-1"/></>,chevron:<path d="m5 3 5 5-5 5"/>,plus:<path d="M8 2v12M2 8h12"/>,minus:<path d="M2 8h12"/>,fit:<><path d="M5.5 2H2v3.5M10.5 2H14v3.5M2 10.5V14h3.5M14 10.5V14h-3.5"/></>,duplicate:<><rect x="5" y="5" width="9" height="9" rx="1"/><path d="M11 5V2H2v9h3"/></>,trash:<><path d="M2.5 4h11M6 4V2.5h4V4M4 4l.7 10h6.6L12 4M6.7 6.5v5M9.3 6.5v5"/></>,up:<path d="m3 10 5-5 5 5"/>,down:<path d="m3 6 5 5 5-5"/>,group:<><rect x="2" y="2" width="5" height="5"/><rect x="9" y="9" width="5" height="5"/><path d="M9 2h5v5M2 9v5h5"/></>,ungroup:<><rect x="2" y="2" width="5" height="5"/><rect x="9" y="9" width="5" height="5"/><path d="M9 2h5v5M2 9v5h5" strokeDasharray="2 2"/></>};return <svg {...common} aria-hidden="true">{paths[k]||<circle cx="8" cy="8" r="5"/>}</svg>}
function Button({label,ico,onClick,active,disabled,className}: {label:string;ico?:string;onClick?:()=>void;active?:boolean;disabled?:boolean;className?:string}){return <button type="button" className={'icon-button '+(active?'active ':'')+(className||'')} title={label} aria-label={label} aria-pressed={active===undefined?undefined:active} disabled={disabled} onClick={onClick}>{ico?icon(ico):label}</button>}
function App(){
const [doc,setDoc]=useState<Doc>(()=>{try{return JSON.parse(localStorage.getItem(storage)||'null')||copy(initial)}catch{return copy(initial)}}),docRef=useRef(doc);docRef.current=doc;
const [selected,setSelected]=useState<string[]>([]),selectedRef=useRef(selected);selectedRef.current=selected;
const [expanded,setExpanded]=useState<Record<string,boolean>>({'menu-card':true,'story':true,'web-banner':true,'header':true,'logo-lockup':true,'swatches':true});
const [tool,setTool]=useState<Kind|'move'>('move'),toolRef=useRef(tool);toolRef.current=tool;
const [view,setView]=useState<View>({z:.55,tx:0,ty:0}),viewRef=useRef(view);viewRef.current=view;
const [past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[message,setMessage]=useState(''),[drag,setDrag]=useState<Drag|null>(null),dragRef=useRef<Drag|null>(null),[guides,setGuides]=useState<{x?:number;y?:number}>({});
const [editing,setEditing]=useState<string|null>(null),[editingNew,setEditingNew]=useState(false),finishingText=useRef(false),[rename,setRename]=useState<string|null>(null),[resetOpen,setResetOpen]=useState(false);
const [mobileTab,setMobileTab]=useState<'layers'|'properties'>('layers');
const canvas=useRef<HTMLDivElement>(null),textArea=useRef<HTMLTextAreaElement>(null),textStartBefore=useRef<Doc|null>(null),space=useRef(false),fitted=useRef(false),msgTimer=useRef<number>(0);
useEffect(()=>{localStorage.setItem(storage,JSON.stringify(doc))},[doc]);
const tell=(s:string)=>{setMessage(s);clearTimeout(msgTimer.current);msgTimer.current=window.setTimeout(()=>setMessage(''),3500)};
const change=(next:Doc,before=docRef.current)=>{if(JSON.stringify(next)===JSON.stringify(before))return;setPast(v=>[...v.slice(-99),copy(before)]);setFuture([]);setDoc(next)};
const undo=()=>{if(!past.length)return;const old=past[past.length-1];setFuture(v=>[copy(docRef.current),...v]);setPast(v=>v.slice(0,-1));setDoc(old);setSelected(v=>v.filter(id=>!!find(old,id)))};
const redo=()=>{if(!future.length)return;const next=future[0];setPast(v=>[...v,copy(docRef.current)]);setFuture(v=>v.slice(1));setDoc(next);setSelected(v=>v.filter(id=>!!find(next,id)))};
const canvasPoint=(clientX:number,clientY:number,v=viewRef.current)=>{const r=canvas.current!.getBoundingClientRect();return {x:(clientX-r.left-v.tx)/v.z,y:(clientY-r.top-v.ty)/v.z}};
const screenPoint=(p:{x:number;y:number},v=viewRef.current)=>{const r=canvas.current!.getBoundingClientRect();return {x:r.left+v.tx+p.x*v.z,y:r.top+v.ty+p.y*v.z}};
const fit=()=>{const el=canvas.current;if(!el)return;const r=el.getBoundingClientRect(),b=union(docRef.current.children.filter(l=>l.visible).map(l=>box(docRef.current,l.id)));const z=clamp(Math.min((r.width-96)/b.width,(r.height-96)/b.height),.1,8);setView({z,tx:(r.width-b.width*z)/2-b.x*z,ty:(r.height-b.height*z)/2-b.y*z})};
useLayoutEffect(()=>{if(!fitted.current){fitted.current=true;fit()}},[]);
const zoom=(z:number|((current:number)=>number),clientX?:number,clientY?:number)=>{const r=canvas.current!.getBoundingClientRect(),cx=clientX??r.left+r.width/2,cy=clientY??r.top+r.height/2;setView(v=>{const p={x:(cx-r.left-v.tx)/v.z,y:(cy-r.top-v.ty)/v.z},nz=clamp(typeof z==='function'?z(v.z):z,.1,8);return {z:nz,tx:cx-r.left-p.x*nz,ty:cy-r.top-p.y*nz}})};
useEffect(()=>{const el=canvas.current;if(!el)return;const wheel=(e:WheelEvent)=>{e.preventDefault();if(e.ctrlKey||e.metaKey)zoom(z=>z*Math.exp(-e.deltaY*.005),e.clientX,e.clientY);else setView(v=>({...v,tx:v.tx-e.deltaX,ty:v.ty-e.deltaY}))};el.addEventListener('wheel',wheel,{passive:false});return()=>el.removeEventListener('wheel',wheel)},[]);
const select=(ids:string[])=>{setSelected(ids);if(ids.length){setExpanded(v=>{const n={...v};ids.forEach(id=>find(docRef.current,id)?.path.slice(0,-1).forEach(l=>n[l.id]=true));return n})}};
const selectionLocked=selected.some(id=>!!find(doc,id)?.path.some(l=>l.locked));
const selectionVisible=selected.every(id=>!!find(doc,id)?.path.every(l=>l.visible));
const activeBox=selected.length&&selectionVisible?(selected.length===1&&find(doc,selected[0])!.layer.type!=='group'?(()=>{const l=find(doc,selected[0])!.layer,o=origin(doc,l.id);return {x:o.x+(l.x||0),y:o.y+(l.y||0),width:l.width||1,height:l.height||1}})():union(selected.map(id=>box(doc,id)))):null;
const getDirectHit=(p:{x:number;y:number},double=false)=>{const path=hitLayer(docRef.current,p);if(!path)return null;let depth=0;const sid=selectedRef.current.length===1?selectedRef.current[0]:null;if(sid){const f=find(docRef.current,sid);const sp=f?.path.filter(l=>l.type!=='frame')||[];const index=sp.findIndex(l=>l.id===sid);if(index>=0&&sp.slice(0,index).every((l,i)=>path[i]?.id===l.id)&&path[index])depth=index+(double?1:0)}else if(double)depth=1;return {target:path[Math.min(depth,path.length-1)],path}};
const parentOf=(id:string)=>find(docRef.current,id)?.parent;
const action=(fn:(d:Doc)=>void)=>{const n=copy(docRef.current);fn(n);change(n)};
const remove=()=>{if(!selectedRef.current.length)return;action(d=>{for(const id of selectedRef.current){const f=find(d,id);if(f)f.parent.children=f.parent.children!.filter(l=>l.id!==id)}});select([])};
const duplicate=()=>{if(!selectedRef.current.length)return;const ids:string[]=[];action(d=>{for(const id of selectedRef.current){const f=find(d,id);if(!f)continue;const c=copy(f.layer);const reid=(l:Layer)=>{l.id=crypto.randomUUID();l.name=l.name+' copy';l.children?.forEach(reid)};reid(c);translate(c,10,10);const arr=f.parent.children!,i=arr.findIndex(l=>l.id===id);arr.splice(i+1,0,c);ids.push(c.id)}});select(ids)};
const order=(dir:number)=>{const ids=selectedRef.current;if(!ids.length)return;action(d=>{for(const id of ids){const f=find(d,id);if(!f)continue;const arr=f.parent.children!,i=arr.findIndex(l=>l.id===id),j=i+dir;if(j>=0&&j<arr.length){[arr[i],arr[j]]=[arr[j],arr[i]]}}})};
const group=()=>{const ids=selectedRef.current;if(ids.length<2){tell('Select at least two sibling layers to group.');return}const fs=ids.map(id=>find(docRef.current,id));if(fs.some(f=>!f||f.layer.type==='frame'||f.parent!==fs[0]!.parent)){tell('Only layers with the same parent can be grouped. Frames cannot be grouped.');return}let gid='';action(d=>{const p=find(d,ids[0])!.parent,arr=p.children!,picked=arr.filter(l=>ids.includes(l.id)),top=Math.max(...picked.map(l=>arr.indexOf(l)));p.children=arr.filter(l=>!ids.includes(l.id));gid=crypto.randomUUID();p.children.splice(top-picked.length+1,0,{id:gid,type:'group',name:'Group '+(countType(d,'group')+1),visible:true,locked:false,children:picked})});select([gid]);setExpanded(v=>({...v,[gid]:true}))};
const ungroup=()=>{const ids=selectedRef.current;if(!ids.length||ids.some(id=>find(docRef.current,id)?.layer.type!=='group')){tell('Select a group to ungroup.');return}const out:string[]=[];action(d=>{ids.forEach(id=>{const f=find(d,id)!;const arr=f.parent.children!,i=arr.indexOf(f.layer);arr.splice(i,1,...f.layer.children!);out.push(...f.layer.children!.map(c=>c.id))})});select(out)};
function countType(d:Doc,t:Kind){let n=0;const scan=(a:Layer|Doc)=>children(a).forEach(l=>{if(l.type===t)n++;scan(l)});scan(d);return n}
function nextNameNumber(d:Doc,t:Kind){const prefix=({rect:'Rectangle',ellipse:'Ellipse',text:'Text'} as any)[t]+' ';let max=0;const scan=(a:Layer|Doc)=>children(a).forEach(l=>{if(l.name.startsWith(prefix)){const n=Number(l.name.slice(prefix.length));if(Number.isInteger(n))max=Math.max(max,n)}scan(l)});scan(d);return max+1}
const startText=(id:string,isNew=false)=>{finishingText.current=false;setEditing(id);setEditingNew(isNew);setTimeout(()=>{textArea.current?.focus();textArea.current?.select()},0)};
const finishText=()=>{if(!editing||finishingText.current)return;finishingText.current=true;const l=find(docRef.current,editing)?.layer;if(!l){setEditing(null);return}const value=textArea.current?.value??l.text??'';if(!value.trim()){const n=copy(docRef.current),f=find(n,editing)!;f.parent.children=f.parent.children!.filter(x=>x.id!==editing);if(editingNew)setDoc(n);else change(n);select([])}else{const n=copy(docRef.current);find(n,editing)!.layer.text=value;if(editingNew)change(n,textStartBefore.current||docRef.current);else change(n)}setEditing(null);setEditingNew(false);textStartBefore.current=null;dragRef.current=null};
const snap=(raw:Box,ids:string[],base:Doc)=>{const siblings:Box[]=[];const one=find(base,ids[0]);if(!one)return {dx:0,dy:0,g:{}};for(const l of children(one.parent))if(!ids.includes(l.id)&&l.visible)siblings.push(box(base,l.id));if(one.frame&&one.layer.type!=='frame')siblings.push(box(base,one.frame.id));let dx=0,dy=0,bx=Infinity,by=Infinity,g:{x?:number;y?:number}={};for(const b of siblings){for(const a of [raw.x,raw.x+raw.width/2,raw.x+raw.width])for(const target of [b.x,b.x+b.width/2,b.x+b.width]){const diff=target-a;if(Math.abs(diff)<bx&&Math.abs(diff)<=5/viewRef.current.z){dx=diff;bx=Math.abs(diff);g.x=target}}for(const a of [raw.y,raw.y+raw.height/2,raw.y+raw.height])for(const target of [b.y,b.y+b.height/2,b.y+b.height]){const diff=target-a;if(Math.abs(diff)<by&&Math.abs(diff)<=5/viewRef.current.z){dy=diff;by=Math.abs(diff);g.y=target}}}return {dx,dy,g}};
const handlePointerDown=(e:React.PointerEvent)=>{if(e.button!==0)return;const active=document.activeElement as HTMLElement;if(active?.closest('.layers-list'))active.blur();const p=canvasPoint(e.clientX,e.clientY),sc={x:e.clientX,y:e.clientY},base=copy(docRef.current),v=viewRef.current;
const target=e.target as HTMLElement;const handle=target.closest('[data-handle]')?.getAttribute('data-handle');if(handle&&selectedRef.current.length&&!selectionLocked){e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const kind=handle==='rotate'?'rotate':'resize',b=union(selectedRef.current.map(id=>box(base,id)));dragRef.current={kind,start:p,startScreen:sc,before:base,ids:[...selectedRef.current],box:b,handle,angle:Math.atan2(p.y-(b.y+b.height/2),p.x-(b.x+b.width/2))};setDrag(dragRef.current);return}
if(space.current||e.pointerType==='touch'&&toolRef.current==='move'&&!getDirectHit(p)){e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);dragRef.current={kind:'pan',start:p,startScreen:sc,before:base,ids:[]};setDrag(dragRef.current);return}
if(toolRef.current!=='move'){e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);dragRef.current={kind:'create',start:p,startScreen:sc,before:base,ids:[],frame:frameAt(base,p)?.id||null,tool:toolRef.current};setDrag(dragRef.current);return}
const frameName=target.closest('[data-frame-name]')?.getAttribute('data-frame-name');if(frameName&&!find(base,frameName)?.layer.locked){select(e.shiftKey?[...selectedRef.current,frameName]:[frameName]);e.currentTarget.setPointerCapture(e.pointerId);dragRef.current={kind:'move',start:p,startScreen:sc,before:base,ids:[frameName],box:box(base,frameName)};setDrag(dragRef.current);return}
const got=getDirectHit(p,e.detail>=2);if(got){const id=got.target.id;if(e.detail>=2&&got.target.type==='text'&&(selectedRef.current.includes(id)||got.path.length===1)){select([id]);startText(id);return}if(e.shiftKey){select(selectedRef.current.includes(id)?selectedRef.current.filter(x=>x!==id):[...selectedRef.current,id]);return}const ids=selectedRef.current.includes(id)?selectedRef.current:[id];select(ids);e.currentTarget.setPointerCapture(e.pointerId);dragRef.current={kind:'move',start:p,startScreen:sc,before:base,ids,box:ids.length===1&&find(base,ids[0])!.layer.type!=='group'?(()=>{const l=find(base,ids[0])!.layer,o=origin(base,l.id);return {x:o.x+(l.x||0),y:o.y+(l.y||0),width:l.width||1,height:l.height||1}})():union(ids.map(i=>box(base,i)))};setDrag(dragRef.current)}else{if(!e.shiftKey)select([]);e.currentTarget.setPointerCapture(e.pointerId);dragRef.current={kind:'marquee',start:p,startScreen:sc,before:base,ids:[],frame:frameAt(base,p)?.id||null};setDrag(dragRef.current)}
};
const resizeBox=(b:Box,h:string,dx:number,dy:number,shift:boolean,alt:boolean)=>{let x=b.x,y=b.y,w=b.width,ht=b.height;const f=alt?2:1;if(h.includes('e'))w=Math.max(1,b.width+dx*f);if(h.includes('w')){w=Math.max(1,b.width-dx*f);x=b.x+b.width-w}if(h.includes('s'))ht=Math.max(1,b.height+dy*f);if(h.includes('n')){ht=Math.max(1,b.height-dy*f);y=b.y+b.height-ht}if(alt){if(h.includes('e'))x=b.x-(w-b.width)/2;if(h.includes('w'))x=b.x-(w-b.width)/2;if(h.includes('s')||h.includes('n'))y=b.y-(ht-b.height)/2}if(shift){const ratio=b.width/b.height;if(Math.abs(w/b.width-1)>Math.abs(ht/b.height-1))ht=w/ratio;else w=ht*ratio;if(h.includes('w'))x=b.x+b.width-w;if(h.includes('n'))y=b.y+b.height-ht;if(alt){x=b.x+(b.width-w)/2;y=b.y+(b.height-ht)/2}}return {x,y,width:w,height:ht}};
const handlePointerMove=(e:React.PointerEvent)=>{const d=dragRef.current;if(!d)return;const p=canvasPoint(e.clientX,e.clientY),dx=p.x-d.start.x,dy=p.y-d.start.y;if(d.kind==='pan'){setView(v=>({...v,tx:v.tx+e.movementX,ty:v.ty+e.movementY}));return}if(d.kind==='marquee'||d.kind==='create'){setDrag({...d,shift:e.shiftKey,current:p});return}const n=copy(d.before);
if(d.kind==='move'){const b=d.box!,s=snap({...b,x:b.x+dx,y:b.y+dy},d.ids,d.before);setGuides(s.g);d.ids.forEach(id=>{const f=find(n,id);if(f)translate(f.layer,dx+s.dx,dy+s.dy)})}
if(d.kind==='rotate'){const f=find(n,d.ids[0]);if(f){const b=d.box!,a=Math.atan2(p.y-(b.y+b.height/2),p.x-(b.x+b.width/2));let deg=(f.layer.rotation||0)+(a-(d.angle||0))*180/Math.PI;if(e.shiftKey)deg=Math.round(deg/15)*15;f.layer.rotation=round(deg)}}
if(d.kind==='resize'){const b=d.box!,h=d.handle!;if(d.ids.length===1){const f=find(n,d.ids[0]);if(f&&f.layer.type!=='group'&&f.layer.type!=='frame'&&(f.layer.rotation||0)){const l=f.layer,rad=(l.rotation||0)*Math.PI/180,ldx=dx*Math.cos(rad)+dy*Math.sin(rad),ldy=-dx*Math.sin(rad)+dy*Math.cos(rad),old={x:l.x||0,y:l.y||0,width:l.width||1,height:l.height||1},nw=resizeBox(old,h,ldx,ldy,e.shiftKey,e.altKey),oc={x:old.x+old.width/2,y:old.y+old.height/2},nc={x:nw.x+nw.width/2,y:nw.y+nw.height/2},cx=nc.x-oc.x,cy=nc.y-oc.y;l.width=round(nw.width);l.height=round(nw.height);l.x=round(old.x+cx*Math.cos(rad)-cy*Math.sin(rad)-(nw.width-old.width)/2);l.y=round(old.y+cx*Math.sin(rad)+cy*Math.cos(rad)-(nw.height-old.height)/2)}else{const nb=resizeBox(b,h,dx,dy,e.shiftKey,e.altKey);d.ids.forEach(id=>{const f=find(n,id)!;if(f.layer.type==='frame'){const l=f.layer;l.x=round(nb.x);l.y=round(nb.y);l.width=round(nb.width);l.height=round(nb.height)}else{const o=origin(n,id);scaleLayer(f.layer,{x:b.x-o.x,y:b.y-o.y,width:b.width,height:b.height},{x:nb.x-o.x,y:nb.y-o.y,width:nb.width,height:nb.height})}})}}else{const nb=resizeBox(b,h,dx,dy,e.shiftKey,e.altKey);d.ids.forEach(id=>{const f=find(n,id)!;const o=origin(n,id);scaleLayer(f.layer,{x:b.x-o.x,y:b.y-o.y,width:b.width,height:b.height},{x:nb.x-o.x,y:nb.y-o.y,width:nb.width,height:nb.height})})}}
setDoc(n);setDrag({...d})};
const handlePointerUp=(e:React.PointerEvent)=>{const d=dragRef.current;if(!d)return;const p=canvasPoint(e.clientX,e.clientY),dist=Math.hypot(e.clientX-d.startScreen.x,e.clientY-d.startScreen.y);if(d.kind==='create'){const n=copy(d.before),t=d.tool!,fr=d.frame?find(n,d.frame)?.layer:null,o=fr?{x:fr.x||0,y:fr.y||0}:{x:0,y:0};let w=Math.abs(p.x-d.start.x),h=Math.abs(p.y-d.start.y),x=Math.min(p.x,d.start.x),y=Math.min(p.y,d.start.y);if(dist<4){w=t==='text'?220:140;h=t==='text'?44:100;x=d.start.x;y=d.start.y}else if(e.shiftKey&&t!=='text'){const side=Math.max(w,h);w=side;h=side;if(p.x<d.start.x)x=d.start.x-side;if(p.y<d.start.y)y=d.start.y-side}const id=crypto.randomUUID(),l:Layer={id,type:t,name:({rect:'Rectangle',ellipse:'Ellipse',text:'Text'} as any)[t]+' '+(nextNameNumber(n,t)),visible:true,locked:false,x:round(x-o.x),y:round(y-o.y),width:round(Math.max(1,w)),height:round(Math.max(1,h)),rotation:0,fill:t==='text'?'#262a29':'#b47156',opacity:1,...(t==='rect'?{corner_radius:0,stroke:null}:{}),...(t==='text'?{text:'',font_size:24,font_weight:500,line_height:1.2,align:'left'}:{})};(fr?fr.children!:n.children).push(l);setTool('move');select([id]);if(t==='text'){textStartBefore.current=d.before;setDoc(n);startText(id,true)}else change(n,d.before)}else if(d.kind==='marquee'&&dist>3){const b={x:Math.min(p.x,d.start.x),y:Math.min(p.y,d.start.y),width:Math.abs(p.x-d.start.x),height:Math.abs(p.y-d.start.y)},arr=d.frame?find(docRef.current,d.frame)?.layer.children||[]:docRef.current.children;select(arr.filter(l=>l.visible&&!l.locked&&intersects(box(docRef.current,l.id),b)).map(l=>l.id))}else if(['move','resize','rotate'].includes(d.kind))change(docRef.current,d.before);setGuides({});setDrag(null);dragRef.current=null};
const field=(key:string,value:string)=>{const ids=selectedRef.current;if(!ids.length)return;const color=key==='fill';if(color&&!/^#[0-9a-fA-F]{6}$/.test(value)){tell('Use a six-digit hex color, such as #C8553D.');return}const num=Number(value);if(!color&&(!Number.isFinite(num)||!value.trim()||(['width','height','font_size'].includes(key)&&num<1)||(key==='opacity'&&(num<0||num>100))||(key==='corner_radius'&&num<0))){tell('Enter a valid value for '+key.replace('_',' ')+'.');return}action(n=>ids.forEach(id=>{const f=find(n,id)!;const l=f.layer;const b=l.type==='group'?localBounds(l):{x:l.x||0,y:l.y||0,width:l.width||1,height:l.height||1};if(['x','y'].includes(key)){const delta=num-(key==='x'?b.x:b.y);translate(l,key==='x'?delta:0,key==='y'?delta:0)}else if(['width','height'].includes(key)){if(l.type==='group'){const target={...b,[key]:num};scaleLayer(l,b,target)}else (l as any)[key]=round(num)}else if(key==='opacity')l.opacity=num/100;else (l as any)[key]=color?value:round(num)}))};
const propertyValue=(key:string)=>{if(!selected.length)return '';const vals=selected.map(id=>{const l=find(doc,id)!.layer,b=l.type==='group'?localBounds(l):{x:l.x||0,y:l.y||0,width:l.width||1,height:l.height||1};if(key==='x'||key==='y'||key==='width'||key==='height')return round((b as any)[key]);if(key==='opacity')return round((l.opacity??1)*100);return (l as any)[key]??''});return vals.every(v=>v===vals[0])?String(vals[0]):'Mixed'};
const Field=({label,k,unit}:{label:string;k:string;unit?:string})=>{const val=propertyValue(k);return <label className="field"><span>{label}</span><input key={k+'-'+selected.join('-')+'-'+val} aria-label={label} defaultValue={val==='Mixed'?'':val} placeholder={val==='Mixed'?'Mixed':''} onKeyDown={e=>{if(e.key==='Enter'){e.currentTarget.blur()}}} onBlur={e=>{if(e.currentTarget.value!==val&&e.currentTarget.value!=='')field(k,e.currentTarget.value);else if(e.currentTarget.value==='')e.currentTarget.value=val==='Mixed'?'':val;e.currentTarget.value=val==='Mixed'?'':val}}/>{unit&&<em>{unit}</em>}</label>};
const rows:Layer[]=[];const flatten=(ls:Layer[])=>ls.slice().reverse().forEach(l=>{rows.push(l);if(expanded[l.id])flatten(l.children||[])});flatten(doc.children);
const row=(l:Layer,depth:number):React.ReactNode=><React.Fragment key={l.id}><div className={'layer-row '+(selected.includes(l.id)?'selected ':'')+(!l.visible?'muted ':'')} style={{paddingLeft:8+depth*17}} draggable onDragStart={e=>{e.dataTransfer.setData('text/plain',l.id);e.dataTransfer.effectAllowed='move'}} onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();const id=e.dataTransfer.getData('text/plain');if(!id||id===l.id)return;const a=find(docRef.current,id),b=find(docRef.current,l.id);if(!a||!b||a.parent!==b.parent){tell('Layers can only be reordered among siblings.');return}action(n=>{const f=find(n,id)!,target=find(n,l.id)!,arr=f.parent.children!,old=arr.indexOf(f.layer);arr.splice(old,1);arr.splice(arr.indexOf(target.layer)+1,0,f.layer)})}}>
{l.children?<button className={'row-expander '+(expanded[l.id]?'open':'')} aria-label={(expanded[l.id]?'Collapse ':'Expand ')+l.name} onClick={()=>setExpanded(v=>({...v,[l.id]:!v[l.id]}))}>{icon('chevron')}</button>:<span className="row-spacer"/>}
<span className={'type-icon type-'+l.type} aria-hidden="true">{l.type==='frame'?'▣':l.type==='group'?'▦':l.type==='text'?'T':l.type==='ellipse'?'●':'■'}</span>
{rename===l.id?<input className="rename" autoFocus defaultValue={l.name} aria-label={'Rename '+l.name} onKeyDown={e=>{if(e.key==='Enter'||e.key==='Escape')e.currentTarget.blur()}} onBlur={e=>{const name=e.target.value.trim();if(name&&name!==l.name)action(d=>{find(d,l.id)!.layer.name=name});setRename(null)}}/>:<button className="row-name" title={l.name} aria-label={l.name+' layer'} onClick={e=>select(e.shiftKey?(selectedRef.current.includes(l.id)?selectedRef.current.filter(x=>x!==l.id):[...selectedRef.current,l.id]):[l.id])} onDoubleClick={()=>setRename(l.id)}>{l.name}</button>}
<button className="row-mini" title={l.visible?'Hide '+l.name:'Show '+l.name} aria-label={l.visible?'Hide '+l.name:'Show '+l.name} onClick={()=>action(d=>{find(d,l.id)!.layer.visible=!l.visible})}>{icon(l.visible?'eye':'hidden')}</button><button className="row-mini" title={l.locked?'Unlock '+l.name:'Lock '+l.name} aria-label={l.locked?'Unlock '+l.name:'Lock '+l.name} onClick={()=>action(d=>{find(d,l.id)!.layer.locked=!l.locked})}>{icon(l.locked?'lock':'unlock')}</button></div>{expanded[l.id]&&l.children?.slice().reverse().map(c=>row(c,depth+1))}</React.Fragment>;
const renderLayer=(l:Layer,fr?:Layer):React.ReactNode=>{if(!l.visible)return null;if(l.type==='frame')return <div className="frame-shell" key={l.id} style={{left:l.x,top:l.y,width:l.width,height:l.height}}><button className="frame-name" data-frame-name={l.id} onClick={e=>e.preventDefault()}>{l.name}</button><div className="frame-art" style={{background:l.fill}}>{l.children?.map(c=>renderLayer(c,l))}</div></div>;if(l.type==='group')return l.children?.map(c=>renderLayer(c,fr));const common:React.CSSProperties={left:l.x,top:l.y,width:l.width,height:l.height,transform:`rotate(${l.rotation||0}deg)`,opacity:l.opacity??1};return <div key={l.id} data-layer={l.id} className={'art-layer art-'+l.type} style={{...common,...(l.type==='text'?{color:l.fill,fontSize:l.font_size,fontWeight:l.font_weight,lineHeight:l.line_height,textAlign:l.align,whiteSpace:'pre-wrap',overflow:'visible'}:{background:l.fill,borderRadius:l.type==='ellipse'?'50%':l.corner_radius,border:l.stroke?`${l.stroke.width}px solid ${l.stroke.color}`:undefined})}}>{l.type==='text'?l.text:null}</div>};
const selectedSingle=selected.length===1?find(doc,selected[0])?.layer:null;
const selectionOverlay=activeBox&&<div className="selection-wrap" style={{left:activeBox.x,top:activeBox.y,width:activeBox.width,height:activeBox.height,transform:selectedSingle&&selectedSingle.type!=='group'&&selectedSingle.type!=='frame'?`rotate(${selectedSingle.rotation||0}deg)`:undefined}}><div className="selection-border"/>{!selectionLocked&&['nw','n','ne','e','se','s','sw','w'].map(h=><button key={h} data-handle={h} aria-label={'Resize '+h} className={'handle '+h} style={{width:9/view.z,height:9/view.z,borderWidth:1/view.z}}/>)}{selectedSingle&&!['frame','group'].includes(selectedSingle.type)&&!selectionLocked&&<><div className="rotate-stem" style={{height:25/view.z,top:-25/view.z}}/><button className="rotate-handle" data-handle="rotate" aria-label="Rotate selection" style={{width:12/view.z,height:12/view.z,top:-32/view.z,borderWidth:1/view.z}}/></>}</div>;
const keydown=(e:KeyboardEvent)=>{const target=e.target as HTMLElement;if(target.closest('input,textarea,[contenteditable=true]'))return;if(e.code==='Space'){space.current=true;e.preventDefault();return}const mod=e.ctrlKey||e.metaKey,k=e.key.toLowerCase();if(mod){if(k==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if(k==='d'){e.preventDefault();duplicate()}else if(k==='g'){e.preventDefault();e.shiftKey?ungroup():group()}else if(e.key===']'||e.code==='BracketRight'){e.preventDefault();order(1)}else if(e.key==='['||e.code==='BracketLeft'){e.preventDefault();order(-1)}return}if(target.closest('.layers-list'))return;if(e.shiftKey&&e.code==='Digit1'){e.preventDefault();fit();return}if(e.shiftKey&&e.code==='Digit0'){e.preventDefault();zoom(1);return}if(['v','r','o','t'].includes(k)&&!e.altKey){setTool(({v:'move',r:'rect',o:'ellipse',t:'text'} as any)[k]);return}if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove();return}if(e.key==='Escape'){const id=selectedRef.current[0];if(!id){setTool('move');return}const f=find(docRef.current,id);const groups=f?.path.slice(0,-1).filter(l=>l.type==='group')||[];select(groups.length?[groups[groups.length-1].id]:[]);return}if(e.key==='Enter'&&selectedRef.current.length===1){const l=find(docRef.current,selectedRef.current[0])?.layer;if(l?.type==='text')startText(l.id);return}if(e.key.startsWith('Arrow')&&selectedRef.current.length){e.preventDefault();const dx=e.key==='ArrowRight'?1:e.key==='ArrowLeft'?-1:0,dy=e.key==='ArrowDown'?1:e.key==='ArrowUp'?-1:0,step=e.shiftKey?10:1;action(d=>selectedRef.current.forEach(id=>{const f=find(d,id);if(f&&!f.layer.locked&&f.path.every(l=>!l.locked))translate(f.layer,dx*step,dy*step)}))}};
useEffect(()=>{document.addEventListener('keydown',keydown);const up=(e:KeyboardEvent)=>{if(e.code==='Space')space.current=false};document.addEventListener('keyup',up);return()=>{document.removeEventListener('keydown',keydown);document.removeEventListener('keyup',up)}},[past,future,selected]);
const layerKey=(e:React.KeyboardEvent)=>{if(!['ArrowDown','ArrowUp','ArrowLeft','ArrowRight'].includes(e.key))return;e.preventDefault();const i=rows.findIndex(l=>l.id===selected[0]);if(e.key==='ArrowDown')select([rows[Math.min(rows.length-1,i+1)]?.id].filter(Boolean));if(e.key==='ArrowUp')select([rows[Math.max(0,i-1)]?.id].filter(Boolean));if(e.key==='ArrowLeft'&&selected[0]){const f=find(doc,selected[0]);if(f?.layer.children&&expanded[selected[0]])setExpanded(v=>({...v,[selected[0]]:false}));else if(f&&'id'in f.parent)select([(f.parent as Layer).id])}if(e.key==='ArrowRight'&&selected[0]){const l=find(doc,selected[0])?.layer;if(l?.children)setExpanded(v=>({...v,[l.id]:true}))}};
return <div className="app"><header className="topbar"><div className="brand"><span className="brand-mark"><i/><i/><i/></span><b>canvely</b></div><span className="top-divider"/><div className="breadcrumb"><span>{doc.workspace}</span><span className="crumb-sep">/</span><strong>{doc.file}</strong><span className="saved-dot" title="Saved on this device"/></div><button className="reset-link" onClick={()=>setResetOpen(true)}>Reset demo</button></header>
<div className="toolstrip"><div className="tool-group"><span className="tool-caption">TOOLS</span>{(['move','rect','ellipse','text'] as const).map((t,i)=><Button key={t} label={['Move (V)','Rectangle (R)','Ellipse (O)','Text (T)'][i]} ico={t} active={tool===t} onClick={()=>setTool(t)}/>)}</div><span className="strip-divider"/><div className="tool-group"><Button label="Undo (Ctrl+Z)" ico="undo" disabled={!past.length} onClick={undo}/><Button label="Redo (Ctrl+Shift+Z)" ico="redo" disabled={!future.length} onClick={redo}/></div><span className="strip-divider"/><div className="tool-group edit-actions"><Button label="Duplicate selection (Ctrl+D)" ico="duplicate" disabled={!selected.length} onClick={duplicate}/><Button label="Delete selection" ico="trash" disabled={!selected.length} onClick={remove}/><Button label="Group selection (Ctrl+G)" ico="group" disabled={!selected.length} onClick={group}/><Button label="Ungroup selection (Ctrl+Shift+G)" ico="ungroup" disabled={!selected.length} onClick={ungroup}/><Button label="Send backward" ico="down" disabled={!selected.length} onClick={()=>order(-1)}/><Button label="Bring forward" ico="up" disabled={!selected.length} onClick={()=>order(1)}/></div><div className="toolbar-spacer"/><span className="tool-hint">{selected.length?`${selected.length} selected`:'Ready to design'}</span></div>
<div className="workspace"><aside className={'sidebar layers-sidebar '+(mobileTab==='layers'?'mobile-active':'')}><div className="panel-heading"><span>Layers</span><span className="panel-count">{rows.length}</span></div><div className="panel-subhead">PAGES & OBJECTS <span>TOP FIRST</span></div><div className="layers-list" role="tree" tabIndex={0} onKeyDown={layerKey}>{doc.children.slice().reverse().map(l=>row(l,0))}</div><div className="panel-footer">Drag layers to change their order.</div></aside>
<main className="canvas-zone"><div className="canvas-topline"><span className="canvas-label"><span className="canvas-indicator"/> Canvas</span><div className="zoom-controls"><Button label="Zoom out" ico="minus" onClick={()=>zoom(z=>z/1.25)}/><span className="zoom-readout">{Math.round(view.z*100)}%</span><Button label="Zoom in" ico="plus" onClick={()=>zoom(z=>z*1.25)}/><span className="zoom-rule"/><Button label="Fit all content (Shift+1)" ico="fit" onClick={fit}/><button className="hundred" onClick={()=>zoom(1)} title="Zoom to 100% (Shift+0)">100%</button></div></div><div className="canvas-viewport" ref={canvas} onPointerDown={handlePointerDown} onDoubleClick={e=>{if(toolRef.current!=='move')return;const p=canvasPoint(e.clientX,e.clientY),got=getDirectHit(p,true);if(!got)return;select([got.target.id]);if(got.target.type==='text'&&(selectedRef.current.includes(got.target.id)||got.path.length===1))startText(got.target.id)}} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} style={{cursor:space.current?'grab':tool==='move'?'default':'crosshair'}}><div className="canvas-world" style={{transform:`translate(${view.tx}px,${view.ty}px) scale(${view.z})`}}>{doc.children.map(l=>renderLayer(l))}{selected.length>0&&selectionOverlay}{guides.x!==undefined&&<div className="guide vertical" style={{left:guides.x,top:-3000,height:7000,width:1/view.z}}/>}{guides.y!==undefined&&<div className="guide horizontal" style={{top:guides.y,left:-3000,width:7000,height:1/view.z}}/>}{drag?.kind==='marquee'&&drag.current&&<div className="marquee" style={{left:Math.min(drag.start.x,drag.current.x),top:Math.min(drag.start.y,drag.current.y),width:Math.abs(drag.current.x-drag.start.x),height:Math.abs(drag.current.y-drag.start.y)}}/>}{editing&&(()=>{const l=find(doc,editing)?.layer;if(!l)return null;const o=origin(doc,editing);return <textarea ref={textArea} className="canvas-text-editor" defaultValue={l.text} style={{left:o.x+(l.x||0),top:o.y+(l.y||0),width:l.width,height:l.height,transform:`rotate(${l.rotation||0}deg)`,color:l.fill,fontSize:l.font_size,fontWeight:l.font_weight,lineHeight:l.line_height,textAlign:l.align,background:'transparent'}} onKeyDown={e=>{e.stopPropagation();if(e.key==='Escape'){e.preventDefault();finishText()}}} onBlur={finishText}/>})()}</div>{drag?.kind==='resize'&&activeBox&&<div className="transform-badge" style={{left:screenPoint({x:activeBox.x+activeBox.width,y:activeBox.y+activeBox.height}).x-canvas.current!.getBoundingClientRect().left,top:screenPoint({x:activeBox.x+activeBox.width,y:activeBox.y+activeBox.height}).y-canvas.current!.getBoundingClientRect().top+8}}>{Math.round(activeBox.width)} × {Math.round(activeBox.height)}</div>}{drag?.kind==='rotate'&&selectedSingle&&<div className="transform-badge">{Math.round(selectedSingle.rotation||0)}°</div>}</div><div className="canvas-status"><span>Hold Space to pan · Scroll to move · Ctrl / Cmd + scroll to zoom</span><span>All changes saved locally</span></div></main>
<aside className={'sidebar properties-sidebar '+(mobileTab==='properties'?'mobile-active':'')}><div className="panel-heading"><span>Properties</span><span className="panel-count">{selected.length?selected.length:'—'}</span></div>{!selected.length?<div className="empty-props"><div className="empty-graphic"><span/><span/><span/></div><h3>Nothing selected</h3><p>Select a layer on the canvas or in the layer list to inspect and edit it.</p></div>:<div className="property-scroll"><div className="selection-title"><span className={'type-icon type-'+(selectedSingle?.type||'group')}>{selectedSingle?.type==='text'?'T':selectedSingle?.type==='ellipse'?'●':selectedSingle?.type==='frame'?'▣':selectedSingle?.type==='group'?'▦':'■'}</span><div><strong>{selectedSingle?.name||`${selected.length} layers`}</strong><small>{selectedSingle?.type||'Multiple selection'}</small></div></div><section className="prop-section"><h4>POSITION & SIZE</h4><div className="field-grid"><Field label="X" k="x"/><Field label="Y" k="y"/><Field label="W" k="width"/><Field label="H" k="height"/></div></section>{selectedSingle?.type!=='frame'&&selectedSingle?.type!=='group'&&<section className="prop-section"><h4>APPEARANCE</h4><div className="field-grid"><Field label="Rotation" k="rotation" unit="°"/><Field label="Opacity" k="opacity" unit="%"/></div></section>}{selectedSingle?.type!=='group'&&<section className="prop-section"><h4>{selectedSingle?.type==='text'?'TEXT COLOR':'FILL'}</h4><div className="color-row"><span className="color-swatch" style={{background:propertyValue('fill')==='Mixed'?'linear-gradient(135deg,#ddd 50%,#777 50%)':propertyValue('fill')}}/><Field label="Hex" k="fill"/></div>{selectedSingle?.type==='rect'&&<div className="field-grid extra"><Field label="Corner radius" k="corner_radius"/></div>}{selectedSingle?.type==='text'&&<div className="field-grid extra"><Field label="Font size" k="font_size"/></div>}</section>}<section className="prop-section quick-section"><h4>ARRANGE</h4><div className="quick-grid"><button onClick={()=>order(-1)}>{icon('down')} Backward</button><button onClick={()=>order(1)}>{icon('up')} Forward</button><button onClick={duplicate}>{icon('duplicate')} Duplicate</button><button onClick={remove}>{icon('trash')} Delete</button></div></section></div>}</aside></div><footer className="app-footer"><span>Canvely is a fictional product created for UIBenchmark.</span><span className="footer-version">DESIGN WORKSPACE / 01</span></footer>
<div className="mobile-tabs"><button className={mobileTab==='layers'?'on':''} onClick={()=>setMobileTab('layers')}>Layers</button><button className={mobileTab==='properties'?'on':''} onClick={()=>setMobileTab('properties')}>Properties</button></div>{message&&<div className="toast" role="status">{message}</div>}{resetOpen&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true" aria-labelledby="reset-title"><div className="modal-symbol">↺</div><h2 id="reset-title">Reset this demo?</h2><p>This restores the original Spring Menu document and clears your undo history. Your current edits will be removed from this browser.</p><div className="modal-actions"><button onClick={()=>setResetOpen(false)}>Cancel</button><button className="danger" onClick={()=>{setDoc(copy(initial));setPast([]);setFuture([]);select([]);setResetOpen(false);setTimeout(fit,0);tell('Original document restored.')}}>Reset document</button></div></div></div>}</div>
}
createRoot(document.getElementById('root')!).render(<App/>);Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.