jcode/skills/single-file-webapp/patterns.md

165 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<div id="menu">
<button id="open" title="Open" aria-label="Open">📂</button>
<button id="save" title="Save" aria-label="Save">💾</button>
<button id="undo" title="Undo" aria-label="Undo">↶</button>
<button id="redo" title="Redo" aria-label="Redo">↷</button>
<button id="add" title="Add" aria-label="Add">+</button>
<button id="more" title="More" aria-label="More">⋯</button>
<span id="status"></span>
</div>
<div class="row"><label>x<input id="px" type="number" inputmode="decimal" class="n4"></label>
<label>y<input id="py" type="number" inputmode="decimal" class="n4"></label>
<label>z<input id="pz" type="number" inputmode="decimal" class="n4"></label></div>
```
```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 };
},
};
```