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

8.2 KiB
Raw Blame History

Snippets for single-file web apps

Layout (menu on top, panels left/right, main area fills)

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)

<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>
: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}
// 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

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

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

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

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)

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)

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 };
  },
};