← back to Quadrille Showroom
public/js/modal-rig.js
156 lines
/*!
* 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 };
})();