- 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.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
type Layer = {
id: string; type: "frame" | "group" | "rect" | "ellipse" | "text"; 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 DocumentModel = { product: string; workspace: string; file: string; fictional_notice: string; children: Layer[] };
type Box = { x: number; y: number; width: number; height: number };
type Drag = { kind: "pan" | "move" | "resize" | "rotate" | "create" | "marquee"; startX: number; startY: number; lastX: number; lastY: number; ids: string[]; before: DocumentModel; handle?: string; angle?: number; startAngle?: number; tool?: string; parentId?: string | null; aspect?: number };
const fresh = (initialDocument: DocumentModel): DocumentModel => JSON.parse(JSON.stringify(initialDocument)) as DocumentModel;
const deep = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
const uid = () => `layer-${Math.random().toString(36).slice(2, 10)}`;
const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const isGroup = (l: Layer) => l.type === "group" || l.type === "frame";
function findLayer(doc: DocumentModel, id: string): Layer | undefined {
const walk = (list: Layer[]): Layer | undefined => { for (const l of list) { if (l.id === id) return l; const found = l.children && walk(l.children); if (found) return found; } return undefined; };
return walk(doc.children);
}
function findParent(doc: DocumentModel, id: string): Layer | null {
const walk = (list: Layer[], parent: Layer | null): Layer | null | undefined => { for (const l of list) { if (l.id === id) return parent; if (l.children) { const p = walk(l.children, l); if (p !== undefined) return p; } } return undefined; };
return walk(doc.children, null) ?? null;
}
function updateLayer(doc: DocumentModel, id: string, fn: (l: Layer) => Layer): DocumentModel {
const walk = (items: Layer[]): Layer[] => items.map(l => l.id === id ? fn(l) : ({ ...l, ...(l.children ? { children: walk(l.children) } : {}) }));
return { ...doc, children: walk(doc.children) };
}
function removeIds(doc: DocumentModel, ids: string[]): DocumentModel {
const set = new Set(ids); const walk = (items: Layer[]): Layer[] => items.filter(l => !set.has(l.id)).map(l => ({ ...l, ...(l.children ? { children: walk(l.children) } : {}) }));
return { ...doc, children: walk(doc.children) };
}
function insertSibling(doc: DocumentModel, parentId: string | null, items: Layer[], at?: number): DocumentModel {
if (parentId === null) { const next = [...doc.children]; next.splice(at ?? next.length, 0, ...items); return { ...doc, children: next }; }
return updateLayer(doc, parentId, l => { const next = [...(l.children ?? [])]; next.splice(at ?? next.length, 0, ...items); return { ...l, children: next }; });
}
function rotatePoint(x: number, y: number, cx: number, cy: number, deg: number) {
const r = deg * Math.PI / 180, dx = x - cx, dy = y - cy;
return { x: cx + dx * Math.cos(r) - dy * Math.sin(r), y: cy + dx * Math.sin(r) + dy * Math.cos(r) };
}
function localBox(l: Layer): Box { return { x: l.x ?? 0, y: l.y ?? 0, width: l.width ?? 0, height: l.height ?? 0 }; }
function rotatedBounds(l: Layer, offsetX = 0, offsetY = 0): Box {
const b = localBox(l); const c = { x: offsetX + b.x + b.width / 2, y: offsetY + b.y + b.height / 2 };
const pts = [[b.x, b.y], [b.x + b.width, b.y], [b.x + b.width, b.y + b.height], [b.x, b.y + b.height]].map(([x,y]) => rotatePoint(offsetX + x!, offsetY + y!, c.x, c.y, l.rotation ?? 0));
const xs = pts.map(p => p.x), ys = pts.map(p => p.y); const x = Math.min(...xs), y = Math.min(...ys);
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
}
function getBox(doc: DocumentModel, id: string): Box | null {
const layer = findLayer(doc, id); if (!layer) return null;
if (layer.type === "frame") return { x: layer.x ?? 0, y: layer.y ?? 0, width: layer.width ?? 1, height: layer.height ?? 1 };
const frame = (() => { let p = findParent(doc, id); while (p && p.type !== "frame") p = findParent(doc, p.id); return p; })();
const ox = frame?.x ?? 0, oy = frame?.y ?? 0;
if (layer.type !== "group") return rotatedBounds(layer, ox, oy);
const boxes = (layer.children ?? []).map(ch => getBox(doc, ch.id)).filter((b): b is Box => !!b);
if (!boxes.length) return { x: ox, y: oy, width: 1, height: 1 };
const x = Math.min(...boxes.map(b => b.x)), y = Math.min(...boxes.map(b => b.y));
return { x, y, width: Math.max(...boxes.map(b => b.x + b.width)) - x, height: Math.max(...boxes.map(b => b.y + b.height)) - y };
}
function getRelativeBox(doc: DocumentModel, id: string): Box | null {
const b = getBox(doc, id); if (!b) return null;
let p = findParent(doc, id); while (p && p.type !== "frame") p = findParent(doc, p.id);
return p ? { ...b, x: b.x - (p.x ?? 0), y: b.y - (p.y ?? 0) } : b;
}
function parentFrame(doc: DocumentModel, id: string): Layer | null { let p = findParent(doc,id); while (p && p.type !== "frame") p = findParent(doc,p.id); return p; }
function boundsFor(doc: DocumentModel, ids: string[]): Box | null {
const boxes = ids.map(id => getBox(doc,id)).filter((b): b is Box => !!b); if (!boxes.length) return null;
const x = Math.min(...boxes.map(b=>b.x)), y = Math.min(...boxes.map(b=>b.y));
return { x, y, width: Math.max(...boxes.map(b=>b.x+b.width))-x, height: Math.max(...boxes.map(b=>b.y+b.height))-y };
}
function descendants(l: Layer): Layer[] { return [l, ...(l.children ?? []).flatMap(descendants)]; }
function App({initialDocument}:{initialDocument:DocumentModel}) {
const [doc, setDoc] = useState<DocumentModel>(() => {
try { const saved = localStorage.getItem("canvely-document-v1"); return saved ? JSON.parse(saved) as DocumentModel : fresh(initialDocument); } catch { return fresh(initialDocument); }
});
const docRef = useRef(doc); docRef.current = doc;
const [selection, setSelection] = useState<string[]>([]); const selectionRef = useRef(selection); selectionRef.current = selection;
const [tool, setTool] = useState("move"); const [zoom, setZoom] = useState(.52); const zoomRef = useRef(zoom); zoomRef.current = zoom;
const [pan, setPan] = useState({ x: 30, y: 25 }); const panRef = useRef(pan); panRef.current = pan;
const [undoStack, setUndoStack] = useState<DocumentModel[]>([]); const [redoStack, setRedoStack] = useState<DocumentModel[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["menu-card","story","web-banner","header","logo-lockup","swatches"]));
const [renameId, setRenameId] = useState<string | null>(null); const [renameValue, setRenameValue] = useState("");
const [toast, setToast] = useState(""); const [guides, setGuides] = useState<{x?:number;y?:number}[]>([]); const [textPanelDraft,setTextPanelDraft]=useState("");
const [marquee, setMarquee] = useState<Box | null>(null); const [editTextId, setEditTextId] = useState<string | null>(null); const [editTextValue, setEditTextValue] = useState("");
const svgRef = useRef<SVGSVGElement>(null); const viewportRef = useRef<HTMLDivElement>(null); const dragRef = useRef<Drag | null>(null); const spaceRef = useRef(false);
const createdTextRef=useRef<string|null>(null);
const fitDone = useRef(false);
useEffect(() => { try { localStorage.setItem("canvely-document-v1", JSON.stringify(doc)); } catch { /* storage may be unavailable */ } }, [doc]);
useEffect(() => { if (!toast) return; const t = window.setTimeout(() => setToast(""), 2800); return () => clearTimeout(t); }, [toast]);
useEffect(() => { if (!selection.length) return; setExpanded(s => { const next=new Set(s); for(const id of selection){let p=findParent(doc,id);while(p){if(p.children?.length)next.add(p.id);p=findParent(doc,p.id);}} return next; }); },[selection,doc]);
const record = useCallback((before: DocumentModel, after: DocumentModel) => {
if (equal(before, after)) return;
setUndoStack(s => [...s.slice(-99), deep(before)]); setRedoStack([]); setDoc(after);
}, []);
const edit = useCallback((fn: (d: DocumentModel) => DocumentModel) => {
const before = deep(docRef.current), after = fn(deep(docRef.current)); record(before, after);
}, [record]);
const undo = useCallback(() => { setUndoStack(s => { if (!s.length) return s; const prev = s[s.length-1]!; setRedoStack(r => [...r, deep(docRef.current)]); setDoc(deep(prev)); return s.slice(0,-1); }); }, []);
const redo = useCallback(() => { setRedoStack(s => { if (!s.length) return s; const next = s[s.length-1]!; setUndoStack(u => [...u, deep(docRef.current)]); setDoc(deep(next)); return s.slice(0,-1); }); }, []);
const fitAll = useCallback(() => {
const el = viewportRef.current; if (!el) return; const w = el.clientWidth, h = el.clientHeight;
const frames = docRef.current.children.filter(l=>l.type === "frame"); if (!frames.length) return;
const box = boundsFor(docRef.current, frames.map(l=>l.id))!; const z = clamp(Math.min((w-72)/box.width,(h-72)/box.height),.1,8);
setZoom(z); setPan({x:(w-box.width*z)/2-box.x*z,y:(h-box.height*z)/2-box.y*z});
}, []);
useEffect(() => { if (!fitDone.current && viewportRef.current) { fitDone.current = true; fitAll(); } }, [fitAll]);
useEffect(() => { const onResize=()=>{ if (window.innerWidth<700) fitAll(); }; window.addEventListener("resize",onResize); return()=>window.removeEventListener("resize",onResize); },[fitAll]);
const pointAt = (ev: PointerEvent | React.PointerEvent): {x:number;y:number} => {
const r=viewportRef.current!.getBoundingClientRect(); return {x:(ev.clientX-r.left-panRef.current.x)/zoomRef.current,y:(ev.clientY-r.top-panRef.current.y)/zoomRef.current};
};
const visibleUnlocked = (l: Layer, inheritedVisible=true, inheritedLocked=false) => inheritedVisible && l.visible && !inheritedLocked && !l.locked;
const hitShape = (layer: Layer, x:number,y:number, ox=0,oy=0) => {
const b=localBox(layer), cx=ox+b.x+b.width/2, cy=oy+b.y+b.height/2; const p=rotatePoint(x,y,cx,cy,-(layer.rotation??0)); const lx=p.x-ox-b.x,ly=p.y-oy-b.y;
if(layer.type==="ellipse"){ const rx=b.width/2,ry=b.height/2; return rx>0&&ry>0&&((lx-rx)/rx)**2+((ly-ry)/ry)**2<=1; }
if(lx<0||ly<0||lx>b.width||ly>b.height)return false;
if(layer.type==="rect"&&(layer.corner_radius??0)>0){ const r=Math.min(layer.corner_radius??0,b.width/2,b.height/2); const px=clamp(lx,r,b.width-r),py=clamp(ly,r,b.height-r);return (lx-px)**2+(ly-py)**2<=r*r; }
return true;
};
const hitPath = (x:number,y:number): Layer[] => {
const walk=(list:Layer[],ox:number,oy:number,anc:Layer[],clip?:Box):Layer[]=>{
for(let i=list.length-1;i>=0;i--){const l=list[i]!;if(!l.visible||l.locked)continue;
if(l.type==="frame"){
const fb={x:l.x??0,y:l.y??0,width:l.width??0,height:l.height??0}; if(x<fb.x||y<fb.y||x>fb.x+fb.width||y>fb.y+fb.height)continue;
const found=walk(l.children??[],fb.x,fb.y,[...anc,l],fb); if(found.length)return found; continue;
}
if(clip&&(x<clip.x||y<clip.y||x>clip.x+clip.width||y>clip.y+clip.height))continue;
if(l.type==="group") {const found=walk(l.children??[],ox,oy,[...anc,l],clip);if(found.length)return found;continue;}
if(hitShape(l,x,y,ox,oy))return [...anc,l];
} return [];
};
return walk(docRef.current.children,0,0,[]);
};
const layerIsLocked = (id:string) => { let l=findLayer(docRef.current,id);while(l){if(l.locked)return true;l=findParent(docRef.current,l.id)!;}return false; };
const frameAt=(x:number,y:number)=>docRef.current.children.find(l=>l.type==="frame"&&l.visible&&!l.locked&&x>= (l.x??0)&&y>= (l.y??0)&&x<= (l.x??0)+(l.width??0)&&y<= (l.y??0)+(l.height??0))??null;
const setToolAndClose=(t:string)=>{setTool(t);};
const commitText=(removeEmpty=false) => {
if(!editTextId)return;const id=editTextId;const next=editTextValue;const before=deep(docRef.current);let after=before;
if(removeEmpty&&!next.trim()) after=removeIds(after,[id]); else after=updateLayer(after,id,l=>({...l,text:next}));
if(createdTextRef.current===id){setDoc(after);if(removeEmpty&&!next.trim())setUndoStack(s=>s.slice(0,-1));createdTextRef.current=null;}else record(before,after);setEditTextId(null);setSelection(removeEmpty&&!next.trim()?[]:[id]);
};
const startTextEdit=(id:string)=>{const l=findLayer(docRef.current,id);if(l?.type!=="text")return;setEditTextId(id);setEditTextValue(l.text??"");window.setTimeout(()=>document.querySelector<HTMLDivElement>("[data-canvas-editor='true']")?.focus(),20);};
const makeLayer=(kind:string,box:Box,parent:Layer|null)=>{
const nums=descendants({id:"",type:"group",name:"",visible:true,locked:false,children:docRef.current.children}).filter(l=>l.type===kind).length+1;const layerName=kind==="rect"?"Rectangle":kind[0]!.toUpperCase()+kind.slice(1);
const base:Layer={id:uid(),type:kind as Layer["type"],name:`${layerName} ${nums}`,visible:true,locked:false,x:box.x,y:box.y,width:Math.max(1,box.width),height:Math.max(1,box.height),rotation:0,fill:kind==="text"?"#3B2A1E":kind==="ellipse"?"#7A4E2D":"#C8553D",opacity:1,...(kind==="rect"?{corner_radius:8,stroke:null}:{}),...(kind==="text"?{text:"Type something",font_size:24,font_weight:500,line_height:1.25,align:"left" as const}:{})};
const parentId=parent?.id??null;let after=insertSibling(deep(docRef.current),parentId,[base]);
record(deep(docRef.current),after);setSelection([base.id]);setTool("move");if(kind==="text"){createdTextRef.current=base.id;setEditTextId(base.id);setEditTextValue(base.text??"");window.setTimeout(()=>document.querySelector<HTMLDivElement>("[data-canvas-editor='true']")?.focus(),20);}
};
useEffect(()=>{
const move=(ev:PointerEvent)=>{
const d=dragRef.current;if(!d)return; const pt=pointAt(ev);
if(d.kind==="pan"){setPan({x:panRef.current.x+(ev.clientX-d.lastX)*1,y:panRef.current.y+(ev.clientY-d.lastY)*1});d.lastX=ev.clientX;d.lastY=ev.clientY;return;}
if(d.kind==="create"){
let dx=pt.x-d.startX,dy=pt.y-d.startY;if(ev.shiftKey&&(d.tool==="rect"||d.tool==="ellipse")){const m=Math.max(Math.abs(dx),Math.abs(dy));dx=Math.sign(dx||1)*m;dy=Math.sign(dy||1)*m;}
setMarquee({x:Math.min(d.startX,d.startX+dx),y:Math.min(d.startY,d.startY+dy),width:Math.abs(dx),height:Math.abs(dy)});return;
}
if(d.kind==="marquee"){setMarquee({x:Math.min(d.startX,pt.x),y:Math.min(d.startY,pt.y),width:Math.abs(pt.x-d.startX),height:Math.abs(pt.y-d.startY)});return;}
if(d.kind==="rotate"){
const id=d.ids[0]!, box=getBox(d.before,id)!;const l=findLayer(d.before,id)!;const c={x:box.x+box.width/2,y:box.y+box.height/2};const a=Math.atan2(pt.y-c.y,pt.x-c.x)*180/Math.PI;let angle=(l.rotation??0)+(a-(d.startAngle??a));if(ev.shiftKey)angle=Math.round(angle/15)*15;
setDoc(updateLayer(deep(d.before),id,n=>({...n,rotation:angle})));d.angle=angle;return;
}
if(d.kind==="move"){
let dx=(pt.x-d.startX),dy=(pt.y-d.startY);const before=d.before;const box=boundsFor(before,d.ids);if(!box)return;
const primary=d.ids.length?findLayer(before,d.ids[0]!):null;const frame=primary?parentFrame(before,primary.id):null;const parentId=primary?findParent(before,primary.id)?.id??null:null;
const siblings=(parentId?findLayer(before,parentId)?.children:before.children)??[];
let snapX:number|undefined,snapY:number|undefined;const tolerance=5/zoomRef.current;
const movingX=[box.x+dx,box.x+dx+box.width/2,box.x+dx+box.width],movingY=[box.y+dy,box.y+dy+box.height/2,box.y+dy+box.height];
const targets:Box[]=[];for(const s of siblings){if(d.ids.includes(s.id)||(s.type==="frame"&&primary?.type!=="frame"))continue;const b=getBox(before,s.id);if(b)targets.push(b);}if(frame)targets.push({x:frame.x??0,y:frame.y??0,width:frame.width??0,height:frame.height??0});
for(const t of targets){for(const tx of [t.x,t.x+t.width/2,t.x+t.width])for(const x of movingX){if(Math.abs(tx-x)<=tolerance&&snapX===undefined){snapX=tx-x;}}for(const ty of [t.y,t.y+t.height/2,t.y+t.height])for(const y of movingY){if(Math.abs(ty-y)<=tolerance&&snapY===undefined){snapY=ty-y;}}}
if(snapX!==undefined)dx+=snapX;if(snapY!==undefined)dy+=snapY;
setGuides([{...(snapX!==undefined?{x:box.x+dx}:{}),...(snapY!==undefined?{y:box.y+dy}:{})}]);
let next=deep(before);for(const id of d.ids){const l=findLayer(next,id);if(!l||layerIsLocked(id))continue;const localDx=dx,localDy=dy;if(l.type==="frame"){next=updateLayer(next,id,n=>({...n,x:(n.x??0)+localDx,y:(n.y??0)+localDy}));}else if(l.type==="group")next=translateChildren(next,id,localDx,localDy);else next=updateLayer(next,id,n=>({...n,x:(n.x??0)+localDx,y:(n.y??0)+localDy}));}
setDoc(next);return;
}
if(d.kind==="resize"){
if(d.ids.length>1){
const old=boundsFor(d.before,d.ids);if(!old)return;const hx=d.handle?.includes("w")?-1:d.handle?.includes("e")?1:0,hy=d.handle?.includes("n")?-1:d.handle?.includes("s")?1:0;
let nw=Math.max(1,old.width+(pt.x-d.startX)*hx),nh=Math.max(1,old.height+(pt.y-d.startY)*hy);const ratio=old.width/Math.max(1,old.height);
if(ev.shiftKey){if(hx&&hy){if(Math.abs(nw-old.width)>Math.abs(nh-old.height))nh=nw/ratio;else nw=nh*ratio;}else if(hx)nh=nw/ratio;else nw=nh*ratio;}
const nx=ev.altKey?old.x-(nw-old.width)/2:hx<0?old.x+old.width-nw:old.x,ny=ev.altKey?old.y-(nh-old.height)/2:hy<0?old.y+old.height-nh:old.y,sx=nw/old.width,sy=nh/old.height;let next=deep(d.before);
for(const selectedId of d.ids){const l=findLayer(d.before,selectedId);const b=getBox(d.before,selectedId);if(!l||!b||layerIsLocked(selectedId))continue;const tx=nx+(b.x-old.x)*sx,ty=ny+(b.y-old.y)*sy;
if(l.type==="group"){const f=parentFrame(d.before,selectedId);next=scaleGroup(next,selectedId,sx,sy,tx,ty,b.x,b.y,f?.x??0,f?.y??0);}
else if(l.type==="frame")next=updateLayer(next,selectedId,n=>({...n,x:tx,y:ty,width:(n.width??1)*sx,height:(n.height??1)*sy}));
else{const f=parentFrame(d.before,selectedId);next=updateLayer(next,selectedId,n=>({...n,x:tx-(f?.x??0),y:ty-(f?.y??0),width:(n.width??1)*sx,height:(n.height??1)*sy}));}
}
setDoc(next);return;
}
const id=d.ids[0]!,layer=findLayer(d.before,id);if(!layer)return;const box=getBox(d.before,id)!;
const isGroup=layer.type==="group";const angle=isGroup||layer.type==="frame"?0:(layer.rotation??0);const localStart=rotatePoint(d.startX,d.startY,box.x+box.width/2,box.y+box.height/2,-angle);const localNow=rotatePoint(pt.x,pt.y,box.x+box.width/2,box.y+box.height/2,-angle);
const rb=layer.type==="group"?getBox(d.before,id)!:localBox(layer);const hx=d.handle?.includes("w")?-1:d.handle?.includes("e")?1:0;const hy=d.handle?.includes("n")?-1:d.handle?.includes("s")?1:0;
let nw=rb.width+(localNow.x-localStart.x)*hx,nh=rb.height+(localNow.y-localStart.y)*hy;nw=Math.max(1,nw);nh=Math.max(1,nh);
if(ev.shiftKey&&d.aspect){if(hx&&hy){if(Math.abs(nw-rb.width)>Math.abs(nh-rb.height))nh=nw/d.aspect;else nw=nh*d.aspect;}else if(hx)nh=nw/d.aspect;else nw=nh*d.aspect;}
const alt=ev.altKey;const left=alt?rb.x-(nw-rb.width)/2:(hx<0?rb.x+rb.width-nw:rb.x);const top=alt?rb.y-(nh-rb.height)/2:(hy<0?rb.y+rb.height-nh:rb.y);
if(layer.type==="group"){
const gb=getBox(d.before,id)!;const fx=nw/Math.max(1,gb.width),fy=nh/Math.max(1,gb.height);const frame=parentFrame(d.before,id);const ox=frame?.x??0,oy=frame?.y??0;const worldLeft=gb.x+(left-rb.x),worldTop=gb.y+(top-rb.y);
setDoc(scaleGroup(d.before,id,fx,fy,worldLeft,worldTop,gb.x,gb.y,ox,oy));
}else if(layer.type==="frame"){
setDoc(updateLayer(deep(d.before),id,l=>({...l,x:left,y:top,width:nw,height:nh})));
}else{
const centerOld={x:rb.x+rb.width/2,y:rb.y+rb.height/2};const centerNew={x:left+nw/2,y:top+nh/2};const rc=rotatePoint(centerNew.x,centerNew.y,centerOld.x,centerOld.y,angle);const frame=parentFrame(d.before,id);const ox=frame?.x??0,oy=frame?.y??0;
setDoc(updateLayer(deep(d.before),id,l=>({...l,x:rc.x-ox-nw/2,y:rc.y-oy-nh/2,width:nw,height:nh})));
}
return;
}
};
const up=()=>{const d=dragRef.current;if(!d)return;dragRef.current=null;setGuides([]);
if(d.kind==="create"){const box=marquee;setMarquee(null);if(box){const kind=d.tool!;const size=kind==="text"?{width:180,height:48}:{width:kind==="ellipse"?100:120,height:kind==="ellipse"?100:80};const final={x:box.width<3?d.startX:box.x,y:box.height<3?d.startY:box.y,width:box.width<3?size.width:box.width,height:box.height<3?size.height:box.height};const target=frameAt(d.startX,d.startY);const local=target?{...final,x:final.x-(target.x??0),y:final.y-(target.y??0)}:final;makeLayer(kind,local,target);}return;}
if(d.kind==="marquee"){const m=marquee;setMarquee(null);if(m&&Math.max(m.width,m.height)>=3){const startFrame=frameAt(d.startX,d.startY);const candidates:Layer[]=startFrame?(startFrame.children??[]):docRef.current.children;const ids=candidates.filter(l=>visibleUnlocked(l,true,false)).filter(l=>{const b=getBox(docRef.current,l.id);return b&&b.x<=m.x+m.width&&b.x+b.width>=m.x&&b.y<=m.y+m.height&&b.y+b.height>=m.y;}).map(l=>l.id);setSelection(ids);}return;}
if((d.kind==="move"||d.kind==="resize"||d.kind==="rotate")&&!equal(d.before,docRef.current))record(d.before,deep(docRef.current));
};
window.addEventListener("pointermove",move);window.addEventListener("pointerup",up);return()=>{window.removeEventListener("pointermove",move);window.removeEventListener("pointerup",up);};
},[marquee,makeLayer,record]);
function translateChildren(input:DocumentModel,id:string,dx:number,dy:number):DocumentModel {
const g=findLayer(input,id);if(!g)return input;let next=input;for(const child of g.children??[]){if(child.type==="group")next=translateChildren(next,child.id,dx,dy);else next=updateLayer(next,child.id,l=>({...l,x:(l.x??0)+dx,y:(l.y??0)+dy}));}return next;
}
function scaleGroup(input:DocumentModel,id:string,sx:number,sy:number,newX:number,newY:number,oldX:number,oldY:number,offsetX:number,offsetY:number):DocumentModel {
const g=findLayer(input,id);if(!g)return input;let next=input;
for(const child of g.children??[]){if(child.type==="group"){const b=getBox(input,child.id)!;const nx=newX+(b.x-oldX)*sx,ny=newY+(b.y-oldY)*sy;next=scaleGroup(next,child.id,sx,sy,nx,ny,oldX,oldY,offsetX,offsetY);}else{next=updateLayer(next,child.id,l=>({...l,x:newX-offsetX+((l.x??0)+offsetX-oldX)*sx,y:newY-offsetY+((l.y??0)+offsetY-oldY)*sy,width:(l.width??1)*sx,height:(l.height??1)*sy}));}}
return next;
}
const onCanvasPointerDown=(ev:React.PointerEvent<SVGSVGElement>)=>{
if(editTextId)return;const pt=pointAt(ev);const target=ev.target as Element;const handle=target.closest?.("[data-handle]")?.getAttribute("data-handle");
if(ev.button!==0)return;
const initialHit=hitPath(pt.x,pt.y);
if(spaceRef.current||ev.pointerType==="touch"&&!initialHit.length){dragRef.current={kind:"pan",startX:pt.x,startY:pt.y,lastX:ev.clientX,lastY:ev.clientY,ids:[],before:deep(docRef.current)};return;}
if(handle&&selection.length){if(selection.some(id=>!layerIsLocked(id))){const b=selection.length===1?getBox(docRef.current,selection[0]!)!:boundsFor(docRef.current,selection)!;const l=selection.length===1?findLayer(docRef.current,selection[0]!)!:null;if(handle==="rotate"&&selection.length===1&&!layerIsLocked(selection[0]!)){dragRef.current={kind:"rotate",startX:pt.x,startY:pt.y,lastX:pt.x,lastY:pt.y,ids:selection,before:deep(docRef.current),startAngle:Math.atan2(pt.y-(b.y+b.height/2),pt.x-(b.x+b.width/2))*180/Math.PI};}
else if(handle!=="rotate")dragRef.current={kind:"resize",startX:pt.x,startY:pt.y,lastX:pt.x,lastY:pt.y,ids:selection,before:deep(docRef.current),handle,aspect:l?(l.width??b.width)/(l.height??b.height):b.width/b.height};}ev.preventDefault();return;}
const frameLabel=target.closest?.("[data-frame-name]")?.getAttribute("data-frame-name");if(frameLabel){const f=findLayer(docRef.current,frameLabel);if(f?.locked)return;setSelection([frameLabel]);dragRef.current={kind:"move",startX:pt.x,startY:pt.y,lastX:pt.x,lastY:pt.y,ids:[frameLabel],before:deep(docRef.current)};return;}
if(tool!=="move") {const kind=tool;dragRef.current={kind:"create",startX:pt.x,startY:pt.y,lastX:pt.x,lastY:pt.y,ids:[],before:deep(docRef.current),tool:kind};setMarquee({x:pt.x,y:pt.y,width:0,height:0});ev.preventDefault();return;}
const path=initialHit;let clicked:Layer|undefined;
if(path.length){const candidates=path.filter(l=>l.type!=="frame");const current=selection[0];const currentIndex=candidates.findIndex(l=>l.id===current);
if(ev.detail>=2){const depth=currentIndex>=0?Math.min(currentIndex+1,candidates.length-1):Math.min(1,candidates.length-1);clicked=candidates[depth];}
else if(currentIndex>=0){clicked=candidates[currentIndex];}else clicked=candidates[0];
}
if(clicked){if(ev.shiftKey){setSelection(s=>s.includes(clicked!.id)?s.filter(x=>x!==clicked!.id):[...s,clicked!.id]);return;}
setSelection([clicked.id]);const clickedParent=findParent(docRef.current,clicked.id);if(ev.detail>=2&&clicked.type==="text"&&(clickedParent?.type==="frame"||!clickedParent||selection.includes(clicked.id)))startTextEdit(clicked.id);
if(!layerIsLocked(clicked.id))dragRef.current={kind:"move",startX:pt.x,startY:pt.y,lastX:pt.x,lastY:pt.y,ids:[clicked.id],before:deep(docRef.current)};
}else if(ev.shiftKey&&selection.length){dragRef.current={kind:"marquee",startX:pt.x,startY:pt.y,lastX:pt.x,lastY:pt.y,ids:[],before:deep(docRef.current)};setMarquee({x:pt.x,y:pt.y,width:0,height:0});}
else {setSelection([]);dragRef.current={kind:"marquee",startX:pt.x,startY:pt.y,lastX:pt.x,lastY:pt.y,ids:[],before:deep(docRef.current)};setMarquee({x:pt.x,y:pt.y,width:0,height:0});}
};
const onWheel=(ev:React.WheelEvent)=>{ev.preventDefault();if(ev.ctrlKey||ev.metaKey){const r=viewportRef.current!.getBoundingClientRect();const px=ev.clientX-r.left,py=ev.clientY-r.top;const old=zoomRef.current;const next=clamp(old*Math.exp(-ev.deltaY*.002),.1,8);const worldX=(px-panRef.current.x)/old,worldY=(py-panRef.current.y)/old;setZoom(next);setPan({x:px-worldX*next,y:py-worldY*next});}else setPan(p=>({x:p.x-ev.deltaX,y:p.y-ev.deltaY}));};
const zoomBy=(factor:number)=>{const el=viewportRef.current;if(!el)return;const r=el.getBoundingClientRect(),px=r.width/2,py=r.height/2,old=zoomRef.current,next=clamp(old*factor,.1,8),wx=(px-panRef.current.x)/old,wy=(py-panRef.current.y)/old;setZoom(next);setPan({x:px-wx*next,y:py-wy*next});};
const selectedLayers=selection.map(id=>findLayer(doc,id)).filter((l):l is Layer=>!!l);const selectedBox=boundsFor(doc,selection);const one=selectedLayers.length===1?selectedLayers[0]:null;
useEffect(()=>{if(one?.type==="text")setTextPanelDraft(one.text??"");},[one?.id]);
useEffect(()=>{if(!selection.length)return;const id=selection[0]!;window.setTimeout(()=>document.querySelector<HTMLElement>(`[data-layer-row="${id}"]`)?.scrollIntoView({block:"nearest"}),0);},[selection,expanded]);
const geometryValue=(key:"x"|"y"|"width"|"height")=>{const vals=selection.map(id=>getRelativeBox(doc,id)?.[key]);if(!vals.length)return "";return vals.every(v=>v===vals[0])?String(Math.round(vals[0]!*100)/100):"Mixed";};
const valueFor=(key:string):string=>{if(!selectedLayers.length)return "";const vals=selectedLayers.map(l=> key==="x"||key==="y"||key==="width"||key==="height"?getRelativeBox(doc,l.id)?.[key]:key==="rotation"?(l.rotation??0):key==="opacity"?Math.round((l.opacity??1)*100):key==="corner_radius"?(l.corner_radius??0):key==="font_size"?(l.font_size??24):key==="fill"?(l.fill??""):(l as unknown as Record<string,unknown>)[key]);return vals.every(v=>v===vals[0])?String(vals[0]):"Mixed";};
const propertyChange=(key:string,raw:string)=>{
const parsed=key==="fill"?raw.trim():Number(raw);if(key==="fill"){if(!/^#[0-9a-fA-F]{6}$/.test(String(parsed)))return false;}else if(!Number.isFinite(parsed))return false;
if((key==="width"||key==="height"||key==="font_size")&&Number(parsed)<1)return false;if(key==="opacity"&&(Number(parsed)<0||Number(parsed)>100))return false;if(key==="corner_radius"&&Number(parsed)<0)return false;
edit(d=>{let next=d;for(const id of selection){const l=findLayer(next,id);if(!l)continue;
if(key==="x"||key==="y"){const old=getRelativeBox(next,id)?.[key]??0;const delta=Number(parsed)-old;if(l.type==="frame")next=updateLayer(next,id,n=>({...n,[key]:(n[key]??0)+delta}));else if(l.type==="group")next=translateChildren(next,id,key==="x"?delta:0,key==="y"?delta:0);else next=updateLayer(next,id,n=>({...n,[key]:(n[key]??0)+delta}));}
else if(key==="width"||key==="height"){
if(l.type==="group"){const b=getBox(next,id)!;const sx=key==="width"?Number(parsed)/Math.max(1,b.width):1,sy=key==="height"?Number(parsed)/Math.max(1,b.height):1;const frame=parentFrame(next,id);next=scaleGroup(next,id,sx,sy,b.x,b.y,b.x,b.y,frame?.x??0,frame?.y??0);}
else next=updateLayer(next,id,n=>({...n,[key]:Number(parsed)}));
}else next=updateLayer(next,id,n=>({...n,[key]:key==="opacity"?Number(parsed)/100:key==="fill"?String(parsed):key==="rotation"?Number(parsed):Number(parsed)}));
}return next;});return true;
};
const PropertyField=({label,k,unit=""}:{label:string;k:string;unit?:string})=><label className="prop-field"><span>{label}</span><div className="input-wrap"><input key={`${selection.join("-")}-${k}-${valueFor(k)}`} aria-label={label} defaultValue={valueFor(k)} placeholder={valueFor(k)==="Mixed"?"Mixed":""} onKeyDown={e=>{if(e.key==="Enter"){e.currentTarget.blur();}}} onBlur={e=>{const v=e.currentTarget.value;if(v===""||v==="Mixed")return;if(!propertyChange(k,v)){e.currentTarget.value=valueFor(k);setToast(`That ${label.toLowerCase()} value isn’t valid.`);}}}/>{unit&&<i>{unit}</i>}</div></label>;
const toggleVisible=(id:string)=>edit(d=>updateLayer(d,id,l=>({...l,visible:!l.visible})));
const toggleLock=(id:string)=>edit(d=>updateLayer(d,id,l=>({...l,locked:!l.locked})));
const beginRename=(l:Layer)=>{setRenameId(l.id);setRenameValue(l.name);};
const finishRename=()=>{if(renameId){const id=renameId,name=renameValue.trim();if(name)edit(d=>updateLayer(d,id,l=>({...l,name})));setRenameId(null);}};
const reorder=(id:string,targetId:string,after:boolean)=>{const parent=findParent(doc,id),tp=findParent(doc,targetId);if((parent?.id??null)!==(tp?.id??null)){setToast("Layers can only move within the same parent.");return;}const list=parent?.children??doc.children;const from=list.findIndex(l=>l.id===id),to=list.findIndex(l=>l.id===targetId);if(from<0||to<0||id===targetId)return;edit(d=>{const p=findParent(d,id);const arr=[...(p?.children??d.children)];const [item]=arr.splice(arr.findIndex(l=>l.id===id),1);let idx=arr.findIndex(l=>l.id===targetId)+(after?1:0);arr.splice(idx,0,item!);return p?updateLayer(d,p.id,l=>({...l,children:arr})):{...d,children:arr};});};
const duplicate=()=>{if(!selection.length)return;const cloneBySource=new Map<string,Layer>();for(const id of selection){const source=findLayer(doc,id);if(!source)continue;const copy=(l:Layer,shift=true):Layer=>({...deep(l),id:uid(),name:`${l.name} copy`,...(l.type==="group"?{}:shift?{x:(l.x??0)+10,y:(l.y??0)+10}:{}),...(l.children?{children:l.children.map(ch=>copy(ch,l.type==="frame"?false:shift))}:{})});cloneBySource.set(id,copy(source));}
let next=deep(doc);const groups=new Map<string|null,string[]>();for(const id of selection){const p=findParent(doc,id);const k=p?.id??null;groups.set(k,[...(groups.get(k)??[]),id]);}
let newIds:string[]=[];for(const [pid,ids]of groups){const list=pid?findLayer(next,pid)?.children??[]:next.children;const idx=Math.max(...ids.map(i=>list.findIndex(l=>l.id===i)))+1;const copies=ids.map(id=>cloneBySource.get(id)!).filter(Boolean);next=insertSibling(next,pid,copies,idx);newIds.push(...copies.map(c=>c.id));}edit(()=>next);setSelection(newIds);};
const deleteSelection=()=>{if(!selection.length)return;edit(d=>removeIds(d,selection));setSelection([]);};
const orderSelection=(step:number)=>{if(!selection.length)return;const id=selection[0]!,p=findParent(doc,id),siblings=p?.children??doc.children,index=siblings.findIndex(l=>l.id===id),dest=index+step;if(dest<0||dest>=siblings.length)return;const target=siblings[dest]!;reorder(id,target.id,step>0);};
const groupSelection=(ungroup=false)=>{
if(ungroup){if(selection.length!==1)return;const g=findLayer(doc,selection[0]!);if(g?.type!=="group")return;const p=findParent(doc,g.id);const list=p?.children??doc.children;const idx=list.findIndex(l=>l.id===g.id);let next=removeIds(deep(doc),[g.id]);next=insertSibling(next,p?.id??null,g.children??[],idx);edit(()=>next);setSelection((g.children??[]).map(l=>l.id));return;}
if(selection.length<2)return;const parentIds=selection.map(id=>findParent(doc,id)?.id??null);if(parentIds.some(id=>id!==parentIds[0])||selectedLayers.some(l=>l.type==="frame")){setToast("Choose two or more layers with the same parent to group them.");return;}
const pid=parentIds[0]??null;const list=pid?findLayer(doc,pid)?.children??[]:doc.children;const chosen=list.filter(l=>selection.includes(l.id));const index=Math.max(...chosen.map(l=>list.indexOf(l)));const group:Layer={id:uid(),type:"group",name:"Group",visible:true,locked:false,children:chosen};let next=removeIds(deep(doc),selection);next=insertSibling(next,pid,[group],index-(list.length-chosen.length));edit(()=>next);setSelection([group.id]);
};
const reset=()=>{if(window.confirm("Reset the demo to the original Spring Menu? Your saved edits and history will be cleared.")){setDoc(fresh(initialDocument));setUndoStack([]);setRedoStack([]);setSelection([]);setTool("move");setTimeout(fitAll,0);}};
useEffect(()=>{const onKey=(e:KeyboardEvent)=>{
const active=document.activeElement as HTMLElement|null;const typing=!!active&&(active.matches("input,textarea,[contenteditable=true]")||editTextId!==null);if(typing)return;if(active?.closest(".layers-panel")&&(e.key==="ArrowDown"||e.key==="ArrowUp"))return;
const mod=e.metaKey||e.ctrlKey;const key=e.key.toLowerCase();
if(e.code==="Space"){spaceRef.current=true;e.preventDefault();return;}
if(mod&&key==="z"){e.preventDefault();e.shiftKey?redo():undo();return;}if(mod&&key==="d"){e.preventDefault();duplicate();return;}if(mod&&key==="g"){e.preventDefault();groupSelection(e.shiftKey);return;}if(mod&&e.key==="]"){e.preventDefault();orderSelection(1);return;}if(mod&&e.key==="["){e.preventDefault();orderSelection(-1);return;}
if(e.shiftKey&&e.key==="1"){e.preventDefault();fitAll();return;}if(e.shiftKey&&e.key==="0"){e.preventDefault();setZoom(1);setPan({x:20,y:20});return;}
if(key==="v"||key==="r"||key==="o"||key==="t"){setTool(key==="v"?"move":key==="r"?"rect":key==="o"?"ellipse":"text");return;}
if(e.key==="Escape"){const l=selection.length===1?findLayer(docRef.current,selection[0]!):null;const p=l?findParent(docRef.current,l.id):null;if(p?.type==="group")setSelection([p.id]);else setSelection([]);return;}
if(e.key==="Enter"&&selection.length===1){const l=findLayer(docRef.current,selection[0]!);if(l?.type==="text")startTextEdit(l.id);return;}
if((e.key==="Delete"||e.key==="Backspace")&&selection.length){e.preventDefault();deleteSelection();return;}
if(e.key.startsWith("Arrow")&&selection.length){e.preventDefault();const v=e.shiftKey?10:1,dx=e.key==="ArrowLeft"?-v:e.key==="ArrowRight"?v:0,dy=e.key==="ArrowUp"?-v:e.key==="ArrowDown"?v:0;edit(d=>{let n=d;for(const id of selection){const l=findLayer(n,id);if(!l||layerIsLocked(id))continue;if(l.type==="frame")n=updateLayer(n,id,x=>({...x,x:(x.x??0)+dx,y:(x.y??0)+dy}));else if(l.type==="group")n=translateChildren(n,id,dx,dy);else n=updateLayer(n,id,x=>({...x,x:(x.x??0)+dx,y:(x.y??0)+dy}));}return n;});}
};
const keyup=(e:KeyboardEvent)=>{if(e.code==="Space")spaceRef.current=false;};window.addEventListener("keydown",onKey);window.addEventListener("keyup",keyup);return()=>{window.removeEventListener("keydown",onKey);window.removeEventListener("keyup",keyup);};},[selection,editTextId,redo,undo,fitAll]);
const drawLayer=(l:Layer,offsetX=0,offsetY=0,insideFrame=false):React.ReactNode=>{
if(!l.visible)return null;
if(l.type==="group")return <g key={l.id} data-layer={l.id}>{(l.children??[]).map(ch=>drawLayer(ch,offsetX,offsetY,insideFrame))}</g>;
if(l.type==="frame"){
const x=l.x??0,y=l.y??0,w=l.width??0,h=l.height??0;const clip=`clip-${l.id}`;
return <g key={l.id}><defs><clipPath id={clip}><rect x="0" y="0" width={w} height={h}/></clipPath></defs><rect x={x} y={y} width={w} height={h} fill={l.fill} className="frame-paper"/><g clipPath={`url(#${clip})`} transform={`translate(${x} ${y})`}>{(l.children??[]).map(ch=>drawLayer(ch,0,0,true))}</g><text x={x} y={y-13} className="frame-name" data-frame-name={l.id}>{l.name}</text></g>;
}
const x=l.x??0,y=l.y??0,w=l.width??0,h=l.height??0,rot=l.rotation??0;const transform=`translate(${offsetX+x} ${offsetY+y}) rotate(${rot} ${w/2} ${h/2})`;const selected=selection.includes(l.id);
const content=l.type==="rect"?<><rect width={w} height={h} rx={l.corner_radius??0} fill={l.fill} opacity={l.opacity??1}/>{l.stroke&&<rect x={l.stroke.width/2} y={l.stroke.width/2} width={Math.max(0,w-l.stroke.width)} height={Math.max(0,h-l.stroke.width)} rx={Math.max(0,(l.corner_radius??0)-l.stroke.width/2)} fill="none" stroke={l.stroke.color} strokeWidth={l.stroke.width}/>}</>:l.type==="ellipse"?<ellipse cx={w/2} cy={h/2} rx={w/2} ry={h/2} fill={l.fill} opacity={l.opacity??1}/>:<foreignObject x="0" y="0" width={w} height={h} style={{overflow:"visible"}}><div className="canvas-text" style={{width:w,height:h,color:l.fill,opacity:l.opacity,fontSize:l.font_size,fontWeight:l.font_weight,lineHeight:l.line_height,textAlign:l.align,whiteSpace:"pre-wrap",overflow:"visible"}}>{l.text}</div></foreignObject>;
return <g key={l.id} transform={transform} data-layer={l.id} className={`art-layer ${selected?"selected-art":""}`}>{content}</g>;
};
const overlay=()=>{
if(!selectedBox||!selection.length)return null;const selectedOne=selection.length===1?findLayer(doc,selection[0]!):null;const canTransform=selection.some(id=>!layerIsLocked(id));const rot=selectedOne&&selectedOne.type!=="group"&&selectedOne.type!=="frame"?(selectedOne.rotation??0):0;let b=selectedBox;
let frame=selectedOne?parentFrame(doc,selectedOne.id):null;const x=selectedOne?.type==="frame"?(selectedOne.x??0):selectedOne&&selectedOne.type!=="group"?(frame?.x??0)+(selectedOne.x??0):b.x;const y=selectedOne?.type==="frame"?(selectedOne.y??0):selectedOne&&selectedOne.type!=="group"?(frame?.y??0)+(selectedOne.y??0):b.y;const w=selectedOne?.type==="frame"||selectedOne&&selectedOne.type!=="group"?selectedOne.width??b.width:b.width;const h=selectedOne?.type==="frame"||selectedOne&&selectedOne.type!=="group"?selectedOne.height??b.height:b.height;
if(selectedOne&&selectedOne.type!=="group")b={x,y,width:w,height:h};const cx=b.x+b.width/2,cy=b.y+b.height/2;
const points:Record<string,{x:number;y:number}>={nw:{x:b.x,y:b.y},n:{x:cx,y:b.y},ne:{x:b.x+b.width,y:b.y},e:{x:b.x+b.width,y:cy},se:{x:b.x+b.width,y:b.y+b.height},s:{x:cx,y:b.y+b.height},sw:{x:b.x,y:b.y+b.height},w:{x:b.x,y:cy}};
const rotate=(p:{x:number;y:number})=>rotatePoint(p.x,p.y,cx,cy,rot);const handlePoints=Object.entries(points).map(([k,p])=>[k,rotate(p)] as const);const rotTop=rotate({x:cx,y:b.y-30});
const outline=rot?Object.values(points).map(rotate).map(p=>`${p.x},${p.y}`).join(" "):null;
return <g className="selection-ui" pointerEvents="none">{rot?<polygon points={outline!} className="selection-outline"/>:<rect x={b.x} y={b.y} width={b.width} height={b.height} className="selection-outline"/>}
{canTransform&&handlePoints.map(([k,p])=><rect key={k} data-handle={k} x={p.x-4/zoom} y={p.y-4/zoom} width={8/zoom} height={8/zoom} rx={1/zoom} className="resize-handle" pointerEvents="all"/>)}
{canTransform&&selection.length===1&&selectedOne&&["rect","ellipse","text"].includes(selectedOne.type)&&<><line x1={rotate({x:cx,y:b.y}).x} y1={rotate({x:cx,y:b.y}).y} x2={rotTop.x} y2={rotTop.y} className="rotation-stem"/><circle data-handle="rotate" cx={rotTop.x} cy={rotTop.y} r={5/zoom} className="rotate-handle" pointerEvents="all"/></>}
<text x={b.x+b.width} y={b.y-12/zoom} className="selection-dim">{Math.round(w)} × {Math.round(h)}{rot?` ${Math.round(rot)}°`:""}</text>
</g>;
};
const layerRow=(l:Layer,depth=0):React.ReactNode=>{
const open=expanded.has(l.id),children=l.children??[];return <React.Fragment key={l.id}><div className={`layer-row ${selection.includes(l.id)?"active":""}`} data-layer-row={l.id} style={{"--depth":depth} as React.CSSProperties} role="treeitem" aria-selected={selection.includes(l.id)} tabIndex={0} 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)reorder(id,l.id,e.clientY>e.currentTarget.getBoundingClientRect().top+e.currentTarget.clientHeight/2);}} onKeyDown={e=>{if(e.key==="ArrowDown"||e.key==="ArrowUp"){e.preventDefault();const rows=[...document.querySelectorAll<HTMLElement>(".layer-row")],i=rows.indexOf(e.currentTarget);rows[clamp(i+(e.key==="ArrowDown"?1:-1),0,rows.length-1)]?.focus();}if(e.key==="F2")beginRename(l);if(e.key==="Enter")setSelection([l.id]);}} onClick={()=>setSelection([l.id])}>
<button className="disclosure" aria-label={`${open?"Collapse":"Expand"} ${l.name}`} onClick={e=>{e.stopPropagation();setExpanded(s=>{const n=new Set(s);open?n.delete(l.id):n.add(l.id);return n;});}}>{children.length?(open?<Icon name="chevron-down"/>:<Icon name="chevron-right"/>):<span className="disclosure-empty"/>}</button>
<span className={`type-glyph ${l.type}`} aria-hidden="true">{l.type==="text"?"T":l.type==="group"?"▧":l.type==="frame"?"▣":l.type==="ellipse"?"○":"▰"}</span>
{renameId===l.id?<input className="rename-input" autoFocus value={renameValue} onClick={e=>e.stopPropagation()} onChange={e=>setRenameValue(e.target.value)} onBlur={finishRename} onKeyDown={e=>{if(e.key==="Enter")finishRename();if(e.key==="Escape")setRenameId(null);}}/>:<span className="row-name" onDoubleClick={e=>{e.stopPropagation();beginRename(l);}}>{l.name}</span>}
<span className="row-actions"><button aria-label={`${l.visible?"Hide":"Show"} ${l.name}`} title={l.visible?"Hide layer":"Show layer"} onClick={e=>{e.stopPropagation();toggleVisible(l.id);}}><Icon name={l.visible?"eye":"eye-off"}/></button><button aria-label={`${l.locked?"Unlock":"Lock"} ${l.name}`} title={l.locked?"Unlock layer":"Lock layer"} onClick={e=>{e.stopPropagation();toggleLock(l.id);}}><Icon name={l.locked?"lock":"unlock"}/></button></span>
</div>{open&&children.slice().reverse().map(ch=>layerRow(ch,depth+1))}</React.Fragment>;
};
return <main className="app-shell">
<header className="topbar"><div className="brand"><div className="brand-mark"><i/><i/><i/></div><span>canvely</span></div><div className="file-context"><span className="workspace-name"><span className="workspace-dot"/>Orchard Studio</span><span className="context-slash">/</span><strong>{doc.file}</strong><span className="saved-status"><i/>Saved</span></div><button className="avatar" aria-label="Account menu">OS</button></header>
<section className="toolbar" aria-label="Editor toolbar"><div className="toolset">{([ ["move","Move","cursor"],["rect","Rectangle","rect"],["ellipse","Ellipse","ellipse"],["text","Text","text"] ] as const).map(([id,label,icon])=><button key={id} className={`tool-button ${tool===id?"active":""}`} aria-label={`${label} tool`} aria-pressed={tool===id} title={`${label} (${id==="move"?"V":id==="rect"?"R":id==="ellipse"?"O":"T"})`} onClick={()=>setToolAndClose(id)}><Icon name={icon}/><span>{label}</span><kbd>{id==="move"?"V":id==="rect"?"R":id==="ellipse"?"O":"T"}</kbd></button>)}</div><div className="toolbar-divider"/><button className="icon-button" aria-label="Undo" title="Undo · Ctrl Z" disabled={!undoStack.length} onClick={undo}><Icon name="undo"/></button><button className="icon-button" aria-label="Redo" title="Redo · Ctrl Shift Z" disabled={!redoStack.length} onClick={redo}><Icon name="redo"/></button><div className="toolbar-spacer"/><div className="zoom-controls"><button className="icon-button" aria-label="Zoom out" onClick={()=>zoomBy(1/1.2)}><Icon name="minus"/></button><button className="zoom-value" aria-label={`Zoom ${Math.round(zoom*100)} percent`}>{Math.round(zoom*100)}%</button><button className="icon-button" aria-label="Zoom in" onClick={()=>zoomBy(1.2)}><Icon name="plus"/></button><span className="zoom-sep"/><button className="icon-button" aria-label="Fit all content" title="Fit all · Shift 1" onClick={fitAll}><Icon name="fit"/></button><button className="icon-button reset-zoom" aria-label="Reset zoom to 100 percent" title="100% · Shift 0" onClick={()=>{setZoom(1);setPan({x:20,y:20});}}>100%</button></div><div className="toolbar-divider reset-divider"/><button className="reset-button" aria-label="Reset demo" onClick={reset}><Icon name="reset"/><span>Reset demo</span></button></section>
<div className="workspace-grid"><aside className="layers-panel panel"><div className="panel-heading"><div><span className="eyebrow">DOCUMENT</span><h2>Layers <span>{descendants({id:"root",type:"group",name:"",visible:true,locked:false,children:doc.children}).length}</span></h2></div><button className="small-icon" aria-label="Layer panel options" title="Layer panel options"><Icon name="more"/></button></div><div className="layer-toolbar"><button aria-label="Group selection" title="Group · Ctrl G" onClick={()=>groupSelection(false)} disabled={selection.length<2}><Icon name="group"/></button><button aria-label="Ungroup selection" title="Ungroup · Ctrl Shift G" onClick={()=>groupSelection(true)} disabled={selection.length!==1||one?.type!=="group"}><Icon name="ungroup"/></button><span/><button aria-label="Bring forward" title="Bring forward · Ctrl ]" onClick={()=>orderSelection(1)}><Icon name="forward"/></button><button aria-label="Send backward" title="Send backward · Ctrl [" onClick={()=>orderSelection(-1)}><Icon name="backward"/></button><button aria-label="Duplicate selection" title="Duplicate · Ctrl D" onClick={duplicate} disabled={!selection.length}><Icon name="duplicate"/></button><button aria-label="Delete selection" title="Delete" onClick={deleteSelection} disabled={!selection.length}><Icon name="trash"/></button></div><div className="tree-list" role="tree" aria-label="Layers">{doc.children.slice().reverse().map(l=>layerRow(l))}</div><div className="panel-foot"><span className="green-indicator"/>All changes saved</div></aside>
<section className="canvas-region"><div className="canvas-topline"><div className="canvas-breadcrumb"><span>Orchard Studio</span><Icon name="chevron-right"/><strong>{doc.file}</strong></div><div className="canvas-hint">{tool==="move"?"Select and arrange":"Drag on canvas to draw"}</div></div><div className="canvas-viewport" ref={viewportRef} onWheel={onWheel}><svg ref={svgRef} className="canvas-svg" width="1200" height="1120" viewBox="0 0 1200 1120" style={{transform:`translate(${pan.x}px, ${pan.y}px) scale(${zoom})`}} onPointerDown={onCanvasPointerDown} onDoubleClick={ev=>{if(editTextId)return;const p=pointAt(ev as unknown as React.PointerEvent);const path=hitPath(p.x,p.y);const l=path[path.length-1];if(l?.type==="text"&&selection.includes(l.id))startTextEdit(l.id);}}>
<defs><pattern id="dots" width="24" height="24" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="#D6D9D8"/></pattern></defs><rect x="-1600" y="-1600" width="4500" height="4500" fill="url(#dots)" pointerEvents="none"/>
{doc.children.map(l=>drawLayer(l))}
{editTextId&&(()=>{const l=findLayer(doc,editTextId);if(!l)return null;const b=getBox(doc,l.id)!;const f=parentFrame(doc,l.id);const x=l.type==="frame"?b.x:(f?.x??0)+(l.x??0),y=l.type==="frame"?b.y:(f?.y??0)+(l.y??0);return <foreignObject x={x} y={y} width={l.width} height={l.height} transform={`rotate(${l.rotation??0} ${x+(l.width??0)/2} ${y+(l.height??0)/2})`}><div data-canvas-editor="true" role="textbox" aria-label={`Edit ${l.name} text`} aria-multiline="true" contentEditable suppressContentEditableWarning className="inline-editor" style={{width:l.width,height:l.height,fontSize:l.font_size,fontWeight:l.font_weight,lineHeight:l.line_height,color:l.fill,textAlign:l.align}} onInput={e=>setEditTextValue(e.currentTarget.textContent??"")} onBlur={()=>commitText(true)} onKeyDown={e=>{if(e.key==="Escape"){e.preventDefault();commitText(true);}if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();commitText(true);}}}>{editTextValue}</div></foreignObject>;})()}
{overlay()}
{guides.map((g,i)=><g key={i} className="snap-guide">{g.x!==undefined&&<line x1={g.x} y1="-1600" x2={g.x} y2="2600"/>}{g.y!==undefined&&<line x1="-1600" y1={g.y} x2="2600" y2={g.y}/>}</g>)}
{marquee&&<rect x={marquee.x} y={marquee.y} width={marquee.width} height={marquee.height} className="marquee"/>}
</svg><div className="canvas-position" aria-hidden="true"><span>{Math.round(pan.x)},{Math.round(pan.y)}</span><span>{Math.round(zoom*100)}%</span></div></div><div className="canvas-footer"><span><kbd>Space</kbd> + drag to pan</span><span>Spring Menu <b>·</b> 3 artboards</span><span>W 1200 <b>×</b> H 1120</span></div></section>
<aside className="properties-panel panel"><div className="panel-heading properties-heading"><div><span className="eyebrow">INSPECTOR</span><h2>Properties</h2></div><button className="small-icon" aria-label="More property options"><Icon name="more"/></button></div>{!selectedLayers.length?<div className="inspector-empty"><div className="empty-cube"><Icon name="cursor"/></div><strong>Nothing selected</strong><span>Select an object on the canvas or in the layer list to inspect its properties.</span></div>:<div className="inspector-scroll"><div className="selection-title"><span className={`selection-type ${one?.type??"mixed"}`}>{one?.type??`${selectedLayers.length} layers`}</span><strong>{one?.name??"Multiple selection"}</strong><button aria-label="Delete selection" onClick={deleteSelection}><Icon name="trash"/></button></div><div className="property-section"><div className="section-label">Position</div><div className="field-grid"><PropertyField label="X" k="x"/><PropertyField label="Y" k="y"/></div><div className="section-label dimension-label">Dimensions</div><div className="field-grid"><PropertyField label="W" k="width"/><PropertyField label="H" k="height"/></div>{selectedLayers.every(l=>["rect","ellipse","text"].includes(l.type))&&<div className="field-grid rotation-field"><PropertyField label="Rotation" k="rotation" unit="°"/></div>}</div>
{one?.type!=="group"&&<div className="property-section appearance-section"><div className="section-label">Appearance</div>{one?.type!=="frame"&&<div className="field-grid"><PropertyField label="Opacity" k="opacity" unit="%"/>{one?.type==="rect"&&<PropertyField label="Radius" k="corner_radius"/>}</div>}{one?.type==="text"&&<div className="field-grid extra-field"><PropertyField label="Font size" k="font_size" unit="px"/></div>}<label className="color-field"><span>Fill</span><div className="color-control"><input aria-label="Fill color" type="color" value={/^#[0-9a-fA-F]{6}$/.test(valueFor("fill"))?valueFor("fill"):"#777777"} onChange={e=>propertyChange("fill",e.target.value)}/><input aria-label="Fill hex value" key={`fill-${selection.join("-")}-${valueFor("fill")}`} defaultValue={valueFor("fill")} onBlur={e=>{if(e.target.value!=="Mixed"&&!propertyChange("fill",e.target.value))e.target.value=valueFor("fill");}} onKeyDown={e=>{if(e.key==="Enter")e.currentTarget.blur();}}/></div></label></div>}
{one?.type==="text"&&<div className="property-section text-content-section"><div className="section-label">Text content</div><textarea aria-label="Text content" value={textPanelDraft} onChange={e=>setTextPanelDraft(e.target.value)} onBlur={()=>{if(textPanelDraft!==(one.text??"")){const before=deep(docRef.current),after=updateLayer(deep(docRef.current),one.id,l=>({...l,text:textPanelDraft}));record(before,after);}}} /></div>}
</div>}</aside>
</div><footer className="notice-bar"><span className="notice-mark">i</span><span>{doc.fictional_notice}</span><span className="notice-key">LOCAL DOCUMENT <i/> NO CLOUD SYNC</span></footer>{toast&&<div className="toast" role="status"><Icon name="info"/>{toast}</div>}
</main>;
}
function Icon({name}:{name:string}){
const common={width:16,height:16,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:1.7,strokeLinecap:"round" as const,strokeLinejoin:"round" as const,"aria-hidden":true as const};
const paths:Record<string,React.ReactNode>={"cursor":<><path d="m5 3 14 10-7 1-3 7z"/><path d="m12 14 4 6"/></>,rect:<rect x="4" y="5" width="16" height="14" rx="1"/>,ellipse:<ellipse cx="12" cy="12" rx="8" ry="7"/>,text:<><path d="M4 6V4h16v2M12 4v16M9 20h6"/></>,undo:<><path d="M9 14 4 9l5-5"/><path d="M4 9h9a6 6 0 0 1 6 6v2"/></>,redo:<><path d="m15 14 5-5-5-5"/><path d="M20 9h-9a6 6 0 0 0-6 6v2"/></>,minus:<path d="M5 12h14"/>,plus:<><path d="M12 5v14M5 12h14"/></>,fit:<><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/><path d="M8 8h8v8H8z"/></>,reset:<><path d="M3 12a9 9 0 1 0 2.64-6.36L3 8"/><path d="M3 3v5h5"/></>,"chevron-right":<path d="m9 18 6-6-6-6"/>,"chevron-down":<path d="m6 9 6 6 6-6"/>,eye:<><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="2.5"/></>,"eye-off":<><path d="m3 3 18 18M10.6 6.2A10.4 10.4 0 0 1 12 6c6.5 0 10 6 10 6a16 16 0 0 1-3.1 3.6M6.2 6.8C3.4 8.6 2 12 2 12s3.5 6 10 6a10 10 0 0 0 4-.8"/></>,lock:<><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 1 1 8 0v3"/></>,unlock:<><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 7-2"/></>,more:<><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,group:<><rect x="3" y="4" width="12" height="12" rx="2"/><rect x="9" y="9" width="12" height="12" rx="2"/></>,ungroup:<><rect x="4" y="4" width="7" height="7" rx="1"/><rect x="13" y="4" width="7" height="7" rx="1"/><rect x="4" y="13" width="7" height="7" rx="1"/><rect x="13" y="13" width="7" height="7" rx="1"/></>,forward:<><path d="m7 15 5-5 5 5"/><path d="M12 10v10M4 4h16"/></>,backward:<><path d="m7 9 5 5 5-5"/><path d="M12 14V4M4 20h16"/></>,duplicate:<><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></>,trash:<><path d="M4 7h16M10 11v6M14 11v6M5 7l1 14h12l1-14M9 7V4h6v3"/></>,info:<><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></>};
return <svg {...common}>{paths[name]??paths.info}</svg>;
}
async function mount(){
const root=createRoot(document.getElementById("root")!);
try{const response=await fetch("/data/document.json");if(!response.ok)throw new Error("The demo document could not be loaded.");const data=await response.json() as DocumentModel;root.render(<App initialDocument={data}/>);}
catch{root.render(<div className="load-error" role="alert">Canvely couldn’t load the Spring Menu document. Reload to try again.</div>);}
}
void mount();