[object Object]

← back to Dw Launches

add modal-rig.js drop-in (vanilla drag/resize/maximize/persist)

8db4dc58fdb3f2fa812a78aea3688943d167412e · 2026-06-19 10:34:28 -0700 · SteveStudio2

Files touched

Diff

commit 8db4dc58fdb3f2fa812a78aea3688943d167412e
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Fri Jun 19 10:34:28 2026 -0700

    add modal-rig.js drop-in (vanilla drag/resize/maximize/persist)
---
 public/modal-rig.js | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 155 insertions(+)

diff --git a/public/modal-rig.js b/public/modal-rig.js
new file mode 100644
index 0000000..a62c5f9
--- /dev/null
+++ b/public/modal-rig.js
@@ -0,0 +1,155 @@
+/*!
+ * modal-rig.js — make any modal/panel "fully done": drag-to-rearrange,
+ * stretch/resize, collapsible sections, maximize, all persisted to localStorage.
+ * Vanilla JS, zero deps, framework-agnostic. Exposes window.ModalRig.
+ *
+ *   ModalRig.rig(panelEl, { key:'mynote' })          // enhance an existing panel
+ *   ModalRig.section(headerEl, bodyEl, 'mynote:links') // collapsible section
+ *   var m = ModalRig.open({ key:'mynote', title:'Note', bodyHtml:'<p>…</p>' })
+ *
+ * Conventions:
+ *  - The panel is absolutely positioned; first child (or opts.handle) is the drag handle.
+ *  - resize:both gives a native corner handle; size + position persist under opts.key.
+ *  - A ⤢ maximize button is injected into the handle unless maximizable:false.
+ */
+(function () {
+  function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; } }
+  function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
+  function px(n) { return Math.round(n) + 'px'; }
+
+  function applyRect(panel, key, opts) {
+    var w = opts.width || 760;
+    var h = opts.height || Math.min(620, Math.round(window.innerHeight * 0.8));
+    var r = lsGet('modalrig:' + key);
+    if (r && r.w) {
+      panel.style.left = px(r.left || 0); panel.style.top = px(r.top || 0);
+      panel.style.width = px(r.w); panel.style.height = px(r.h);
+    } else {
+      panel.style.left = px(Math.max(0, (window.innerWidth - w) / 2));
+      panel.style.top = px(Math.max(0, (window.innerHeight - h) / 2));
+      panel.style.width = px(w); panel.style.height = px(h);
+    }
+  }
+
+  function saveRect(panel, key) {
+    if (panel.getAttribute('data-rig-max') === '1') return; // don't persist the maximized state
+    lsSet('modalrig:' + key, {
+      left: parseInt(panel.style.left) || 0, top: parseInt(panel.style.top) || 0,
+      w: panel.offsetWidth, h: panel.offsetHeight
+    });
+  }
+
+  function startDrag(e, panel, key) {
+    if (e.target.closest && e.target.closest('[data-rig-btn]')) return; // not when clicking header buttons
+    if (panel.getAttribute('data-rig-max') === '1') unmaximize(panel, key); // dragging exits maximize
+    var box = panel.getBoundingClientRect();
+    var dx = e.clientX - box.left, dy = e.clientY - box.top;
+    function move(ev) {
+      panel.style.left = px(Math.max(0, Math.min(window.innerWidth - 80, ev.clientX - dx)));
+      panel.style.top = px(Math.max(0, Math.min(window.innerHeight - 40, ev.clientY - dy)));
+    }
+    function up() { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); saveRect(panel, key); }
+    document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
+    e.preventDefault();
+  }
+
+  function maximize(panel) {
+    panel.setAttribute('data-rig-pre', JSON.stringify({ left: panel.style.left, top: panel.style.top, w: panel.style.width, h: panel.style.height }));
+    panel.setAttribute('data-rig-max', '1');
+    panel.style.left = '2vw'; panel.style.top = '2vh'; panel.style.width = '96vw'; panel.style.height = '96vh';
+  }
+  function unmaximize(panel, key) {
+    var pre = null;
+    try { pre = JSON.parse(panel.getAttribute('data-rig-pre') || 'null'); } catch (e) { pre = null; }
+    panel.removeAttribute('data-rig-max');
+    if (pre) { panel.style.left = pre.left; panel.style.top = pre.top; panel.style.width = pre.w; panel.style.height = pre.h; }
+  }
+  function toggleMax(panel, key, btn) {
+    if (panel.getAttribute('data-rig-max') === '1') { unmaximize(panel, key); if (btn) btn.textContent = '⤢'; }
+    else { maximize(panel); if (btn) btn.textContent = '⤡'; }
+  }
+
+  function injectMaxBtn(handle, panel, key) {
+    if (handle.querySelector('[data-rig-max-btn]')) return;
+    var b = document.createElement('span');
+    b.setAttribute('data-rig-btn', '1'); b.setAttribute('data-rig-max-btn', '1');
+    b.title = 'maximize / restore'; b.textContent = '⤢';
+    b.style.cssText = 'cursor:pointer; opacity:.7; font-size:14px; line-height:1; margin-left:8px;';
+    b.addEventListener('click', function (e) { e.stopPropagation(); toggleMax(panel, key, b); });
+    handle.appendChild(b);
+  }
+
+  // Enhance an already-in-DOM panel.
+  function rig(panel, opts) {
+    if (!panel) return panel;
+    opts = opts || {};
+    var key = opts.key || 'default';
+    panel.style.position = 'absolute';
+    if (opts.resizable !== false) { panel.style.resize = 'both'; if (!panel.style.overflow) panel.style.overflow = 'hidden'; }
+    applyRect(panel, key, opts);
+    var handle = typeof opts.handle === 'string' ? panel.querySelector(opts.handle) : (opts.handle || panel.firstElementChild);
+    if (handle) {
+      handle.style.cursor = 'move'; handle.style.userSelect = 'none';
+      handle.addEventListener('mousedown', function (e) { startDrag(e, panel, key); });
+      if (opts.maximizable !== false) injectMaxBtn(handle, panel, key);
+    }
+    panel.addEventListener('mouseup', function () { saveRect(panel, key); });
+    // re-fit any registered onResize (e.g. a canvas/graph) when resized
+    if (opts.onResize && window.ResizeObserver) {
+      var ro = new ResizeObserver(function () { try { opts.onResize(panel); } catch (e) {} });
+      ro.observe(panel);
+    }
+    return panel;
+  }
+
+  // Collapsible section: clicking headerEl toggles bodyEl; state persists under key.
+  function section(headerEl, bodyEl, key) {
+    if (!headerEl || !bodyEl) return;
+    var collapsed = lsGet('modalrig-sec:' + key) === true;
+    var caret = document.createElement('span');
+    caret.textContent = collapsed ? '▸ ' : '▾ ';
+    headerEl.insertBefore(caret, headerEl.firstChild);
+    headerEl.style.cursor = 'pointer'; headerEl.style.userSelect = 'none';
+    bodyEl.style.display = collapsed ? 'none' : '';
+    headerEl.addEventListener('click', function () {
+      var nowOpen = bodyEl.style.display === 'none';
+      bodyEl.style.display = nowOpen ? '' : 'none';
+      caret.textContent = nowOpen ? '▾ ' : '▸ ';
+      lsSet('modalrig-sec:' + key, !nowOpen);
+    });
+  }
+
+  // Build a complete modal (backdrop + panel + header) and rig it. Returns
+  // { backdrop, panel, body, close }. Closes on backdrop click, ×, and Esc.
+  function open(o) {
+    o = o || {};
+    var key = o.key || 'modal';
+    var old = document.getElementById('modalrig-bd-' + key); if (old) old.remove();
+    var bd = document.createElement('div');
+    bd.id = 'modalrig-bd-' + key;
+    bd.style.cssText = 'position:fixed; inset:0; z-index:' + (o.z || 10000) + '; background:rgba(0,0,0,.5);';
+    var panel = document.createElement('div');
+    panel.style.cssText = 'display:flex; flex-direction:column; min-width:340px; min-height:180px; background:' + (o.bg || 'var(--card,#16181d)') + '; border:1px solid var(--border,#333); border-radius:10px; box-shadow:0 16px 56px rgba(0,0,0,.6);';
+    panel.addEventListener('click', function (e) { e.stopPropagation(); });
+    var head = document.createElement('div');
+    head.style.cssText = 'flex:0 0 auto; display:flex; justify-content:space-between; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--border,#333); background:var(--bg,#0a0a0f); border-radius:10px 10px 0 0;';
+    var title = document.createElement('span');
+    title.style.cssText = 'font-weight:600; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
+    title.textContent = o.title || '';
+    var btns = document.createElement('span'); btns.style.cssText = 'flex:0 0 auto; display:flex; align-items:center;';
+    var x = document.createElement('span'); x.setAttribute('data-rig-btn', '1'); x.textContent = '×';
+    x.title = 'close (Esc)'; x.style.cssText = 'cursor:pointer; opacity:.7; font-size:18px; line-height:1; margin-left:10px;';
+    var body = document.createElement('div');
+    body.style.cssText = 'flex:1 1 auto; overflow:auto; padding:14px 18px;';
+    if (o.bodyHtml != null) body.innerHTML = o.bodyHtml; else if (o.bodyEl) body.appendChild(o.bodyEl);
+    btns.appendChild(x); head.appendChild(title); head.appendChild(btns);
+    panel.appendChild(head); panel.appendChild(body); bd.appendChild(panel); document.body.appendChild(bd);
+    function close() { bd.remove(); if (o.onClose) o.onClose(); }
+    bd.addEventListener('click', close); x.addEventListener('click', close);
+    if (!window.__modalRigEsc) { window.__modalRigEsc = true; document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var t = document.querySelector('[id^="modalrig-bd-"]'); if (t) t.remove(); } }); }
+    rig(panel, { key: key, handle: head, resizable: o.resizable, maximizable: o.maximizable, width: o.width, height: o.height, onResize: o.onResize });
+    return { backdrop: bd, panel: panel, body: body, close: close };
+  }
+
+  window.ModalRig = { rig: rig, section: section, open: open };
+})();

← b3d1a74 Design refinement: DW luxury editorial theme for social comp  ·  back to Dw Launches  ·  server: add /api/streams (per-channel segmented board) + /ap c49590a →