165 lines
8.2 KiB
Markdown
165 lines
8.2 KiB
Markdown
# 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 };
|
||
},
|
||
};
|
||
```
|