← 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 →