# Snippets for single-file web apps ## Layout (menu on top, panels left/right, main area fills) ```css html,body{margin:0;height:100%;font:14px system-ui} body{display:grid;grid-template-rows:40px 1fr;grid-template-columns:200px 1fr 240px; grid-template-areas:"menu menu menu" "left main right"} #menu{grid-area:menu} #left{grid-area:left;overflow:auto} #main{grid-area:main;position:relative} #right{grid-area:right;overflow:auto} canvas{display:block} ``` Size a canvas/renderer from `#main.clientWidth/clientHeight`, and again on `resize`. ## Mobile layout (compact toolbar, side panels as bottom sheet, small popups) ```html
``` ```css :root{--gap:4px;--tap:40px} *{box-sizing:border-box} html,body{margin:0;height:100%;overflow:hidden;font:14px/1.25 system-ui;touch-action:manipulation} body{height:100dvh;display:grid;grid-template-rows:auto 1fr;grid-template-columns:1fr; grid-template-areas:"menu" "main"} /* one-row toolbar: icon buttons, scrolls sideways instead of wrapping */ #menu{grid-area:menu;display:flex;gap:var(--gap);padding:calc(var(--gap)) var(--gap); padding-top:max(var(--gap),env(safe-area-inset-top));overflow-x:auto;white-space:nowrap;align-items:center} button{min-width:var(--tap);min-height:var(--tap);padding:0 8px;font-size:18px;line-height:1} #status{margin-left:auto;font-size:12px;opacity:.7} #main{grid-area:main;position:relative;min-height:0} canvas{display:block;touch-action:none} /* compact fields: width fits the content, text >=16px (no iOS zoom), small padding */ input,select{font-size:16px;padding:2px 4px;min-height:32px} .n4{width:5ch} .n6{width:7ch} .name{width:12ch} .hex{width:8ch} .row{display:flex;flex-wrap:wrap;gap:var(--gap) 8px;align-items:center;margin:2px 0} .row label{display:flex;align-items:center;gap:2px} /* tiny label left of the field */ /* side panels: drawer / bottom sheet on phones, fixed columns on wide screens */ #left,#right{background:Canvas;overflow:auto;padding:var(--gap)} @media (max-width:700px){ #left,#right{position:fixed;left:0;right:0;bottom:0;max-height:40dvh;z-index:5; border-top:1px solid #8886;transform:translateY(100%);transition:transform .15s} #left.open,#right.open{transform:none} /* toggled by a ☰ / ⚙ button; main area stays visible above */ } @media (min-width:701px){ body{grid-template-columns:200px 1fr 240px;grid-template-areas:"menu menu menu" "left main right"} #left{grid-area:left} #right{grid-area:right} } /* popups: small, anchored, scrollable inside, never full-screen */ .popup{position:fixed;z-index:10;max-width:min(92vw,360px);max-height:50dvh;overflow:auto; background:Canvas;color:CanvasText;border:1px solid #8886;border-radius:6px;padding:6px; box-shadow:0 4px 16px #0004} .popup[hidden]{display:none} .toast{position:fixed;left:50%;bottom:8px;transform:translateX(-50%);max-width:90vw; background:#222;color:#fff;padding:4px 10px;border-radius:12px;font-size:13px;z-index:20;pointer-events:none} ``` ```js // popup anchored below its button, kept inside the viewport; closes on outside tap / Escape function openPopup(pop, anchor) { document.querySelectorAll('.popup').forEach(p => p.hidden = true); // only one open pop.hidden = false; const a = anchor.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight; pop.style.left = Math.max(4, Math.min(a.left, innerWidth - w - 4)) + 'px'; pop.style.top = (a.bottom + 4 + h > innerHeight ? Math.max(4, a.top - h - 4) : a.bottom + 4) + 'px'; } document.addEventListener('pointerdown', e => { if (!e.target.closest('.popup, #menu button')) document.querySelectorAll('.popup').forEach(p => p.hidden = true); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.popup').forEach(p => p.hidden = true); }); function toast(msg, ms = 2500) { // instead of alert() const t = Object.assign(document.createElement('div'), { className: 'toast', textContent: msg }); document.body.append(t); setTimeout(() => t.remove(), ms); } ``` Canvas: `canvas.style.touchAction = 'none'` and handle `pointerdown/move/up` (+ two-pointer pinch) so dragging does not scroll the page. Resize on `resize` and `orientationchange`. ## UI wiring ```js const $ = id => document.getElementById(id); $('save').addEventListener('click', () => download('scene.json', JSON.stringify(toJSON(), null, 2))); $('posX').addEventListener('input', e => { if (sel) { sel.position.x = +e.target.value; render(); } }); document.addEventListener('keydown', e => { if (e.target.tagName === 'INPUT') return; // don't steal keys from inputs if (e.key === 'Delete') removeSelected(); if ((e.ctrlKey || e.metaKey) && e.key === 'z') undo(); }); ``` ## Download / file import ```js function download(name, data, type = 'text/plain') { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([data], { type })); a.download = name; a.click(); URL.revokeObjectURL(a.href); } function pickFile(accept, asText, cb) { // asText=false -> ArrayBuffer (binary STL) const inp = document.createElement('input'); inp.type = 'file'; inp.accept = accept; inp.onchange = () => { const r = new FileReader(); r.onload = () => cb(r.result, inp.files[0].name); asText ? r.readAsText(inp.files[0]) : r.readAsArrayBuffer(inp.files[0]); }; inp.click(); } ``` ## Serialization with nested groups ```js function objToJSON(o) { return { id: o.id, type: o.type, params: { ...o.params }, hole: !!o.hole, color: o.color, position: [...o.position], rotation: [...o.rotation], children: o.type === 'group' ? o.children.map(objToJSON) : undefined }; } const toJSON = () => ({ version: 1, grid: state.grid, objects: state.objects.map(objToJSON) }); const fromJSON = d => { state.objects = d.objects.map(objFromJSON); state.grid = d.grid ?? 1; state.selection.clear(); render(); }; ``` `objFromJSON` is the only place that creates runtime objects from data - the "add shape" buttons should call it too (`objFromJSON({type:'box', params:{...}})`), so there is exactly one constructor path. ## Undo / redo via snapshots ```js const history = [], future = []; function commit() { history.push(JSON.stringify(toJSON())); future.length = 0; if (history.length > 50) history.shift(); } function undo() { if (!history.length) return; future.push(JSON.stringify(toJSON())); fromJSON(JSON.parse(history.pop())); } ``` Call `commit()` BEFORE each mutation. ## Selection with shift-click (bounding boxes drawn by render) ```js function pick(ev) { /* hit test -> object or null */ } canvas.addEventListener('pointerdown', ev => { const o = pick(ev); if (!ev.shiftKey) state.selection.clear(); if (o) state.selection.has(o) ? state.selection.delete(o) : state.selection.add(o); render(); updatePanel(); }); ``` ## Test hook (last lines of the module) ```js window.__app = { state, toJSON, fromJSON, toSTL, add: (type, params = {}) => { const o = objFromJSON({ type, params }); state.objects.push(o); render(); return o.id; }, select: ids => { state.selection = new Set(ids); render(); updatePanel(); }, project: id => { // three.js: world position -> page pixels (for click tests) const o = state.objects.find(x => x.id === id); const v = new THREE.Vector3(...o.position).project(camera); const r = renderer.domElement.getBoundingClientRect(); return { x: r.left + (v.x + 1) / 2 * r.width, y: r.top + (1 - v.y) / 2 * r.height }; }, }; ```