// Generated, do not edit. sha256:bcd0e4843e173e4a2001dd1d3c642e65fcb325797f24f09e8d0e152ff13debcf import { getPref, setPref } from '../data/preferences.js'; import { activateProfile, activeProfile, createProfile, deleteProfile, listProfiles, listWidgets, renameProfile, saveWidgets, widgetParams, } from '../data/dashboard.js'; import * as dialog from '../ui/dialog.js'; import { DASHBOARD } from '../../shared/config.js'; import { t } from '../i18n/index.js'; import { CLOSE, PLUS, GRIP, ADJUST, PENCIL, TRASH } from '../ui/icons.js'; import { esc } from '../ui/escape.js'; import * as loading from '../ui/loading.js'; import { freeRunAt, fittingCatalog, placeInRun } from './free-space.js'; const LAYOUT_SCOPE = 'dashboard'; const MODEL_KEY = 'heightModel'; const LAYOUT_MODEL_KEY = 'layoutModel'; let masonryRO = null; let masonryResizeBound = false; let masonrySettleTimer = null; let masonryRevealTimer = null; let masonryMO = null; let growCheckTimer = null; function clampSpan(w) { const n = Number(w); if (!Number.isFinite(n)) return DASHBOARD.MIN_SPAN; return Math.max(DASHBOARD.MIN_SPAN, Math.min(DASHBOARD.MAX_SPAN, Math.round(n))); } function clampH(h) { const n = Number(h); if (!Number.isFinite(n)) return DASHBOARD.MIN_H; return Math.max(DASHBOARD.MIN_H, Math.min(DASHBOARD.MAX_H, Math.round(n))); } function stepPx() { const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; return DASHBOARD.HEIGHT_STEP_REM * rootPx; } function pxForSteps(n, step, gap) { return n > 0 ? n * step + (n - 1) * gap : 0; } function stepsForPx(px, step, gap) { return Math.max(1, Math.ceil((px + gap) / (step + gap))); } function newInstance(c) { const def = DASHBOARD.WIDGETS[c.type]; return { instId: c.instId, type: c.type, params: c.params || null, w: def.defaultSpan, h: 0, x: 0, y: 0 }; } function cellSteps(it) { if (it.h > 0) return clampH(it.h); const def = DASHBOARD.WIDGETS[it.type]; return def && def.defaultH != null ? clampH(def.defaultH) : DASHBOARD.SKELETON_STEPS; } function spanSteps(it) { return Math.max(DASHBOARD.MIN_H, cellSteps(it)); } function packLayout(list) { const cols = DASHBOARD.GRID_COLUMNS; const bottom = new Array(cols).fill(0); for (const it of list) { const w = clampSpan(it.w); const h = Math.max(DASHBOARD.MIN_H, cellSteps(it)); const own = Math.max(0, Math.min(cols - w, Math.round(Number(it.x) || 0))); let bestX = 0, bestY = Infinity; for (let x = 0; x + w <= cols; x++) { let y = 0; for (let c = x; c < x + w; c++) if (bottom[c] > y) y = bottom[c]; if (y < bestY || (y === bestY && x === own)) { bestY = y; bestX = x; } } it.x = bestX; it.y = bestY; for (let c = bestX; c < bestX + w; c++) bottom[c] = bestY + h; } return list; } function rectsOverlap(a, b) { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + spanSteps(b) && b.y < a.y + spanSteps(a); } function makeRoom(list, anchors) { const anchorSet = new Set(anchors); const queue = anchors.slice(); let guard = 0; while (queue.length && guard++ < 2000) { const b = list[queue.shift()]; const bBottom = b.y + spanSteps(b); for (let i = 0; i < list.length; i++) { if (anchorSet.has(i)) continue; const a = list[i]; if (a === b) continue; if (rectsOverlap(a, b) && bBottom > a.y) { a.y = bBottom; queue.push(i); } } } } function layoutBottom(list) { let bottom = 0; for (const it of list) { const b = (Math.max(0, Math.round(Number(it.y) || 0))) + Math.max(DASHBOARD.MIN_H, cellSteps(it)); if (b > bottom) bottom = b; } return bottom; } export async function readLayout() { const list = await listWidgets((await activeProfile()).id); const model = Number(await getPref(LAYOUT_SCOPE, MODEL_KEY, 0)) || 0; const hinted = model >= DASHBOARD.HEIGHT_MODEL; const layoutModel = Number(await getPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, 0)) || 0; const placed = layoutModel >= DASHBOARD.LAYOUT_MODEL; const out = list .filter(r => DASHBOARD.WIDGETS[r.type]) .map(r => { const def = DASHBOARD.WIDGETS[r.type]; const h = (hinted && r.spanHeight > 0) ? clampH(r.spanHeight) : 0; const w = clampSpan(r.spanWidth != null ? r.spanWidth : def.defaultSpan); return { instId: String(r.instanceKey || r.type), type: r.type, params: widgetParams(r), w, h, x: Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, Math.round(Number(r.gridX) || 0))), y: placed ? Math.max(0, Math.round(Number(r.gridY) || 0)) : 0, }; }); if (!placed) packLayout(out); return out; } async function writeLayout(list) { try { const profileId = (await activeProfile()).id; await saveWidgets(profileId, list.map(it => ({ instanceKey: it.instId, type: it.type, params: it.params || null, gridX: it.x, gridY: it.y, spanWidth: it.w, spanHeight: it.h > 0 ? it.h : null, }))); await setPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, DASHBOARD.LAYOUT_MODEL); } catch { } } function errCard(e) { return `
\u26A0 ${esc((e && e.message) || e)}
`; } function editBarHtml(it, i, hasSettings) { let opts = ''; for (let n = DASHBOARD.MIN_SPAN; n <= DASHBOARD.MAX_SPAN; n++) opts += ``; const widthSel = ``; const cfgBtn = hasSettings ? `` : ''; return `
${GRIP} ${widthSel} ${cfgBtn}
`; } function cellHtml(it, i, edit, hasSettings) { const x = Math.max(0, Math.round(Number(it.x) || 0)); const y = Math.max(0, Math.round(Number(it.y) || 0)); const span = spanSteps(it); const cls = `dashboard-cell${it.h > 0 ? ' is-sized' : ''}`; const place = `grid-column: ${x + 1} / span ${it.w}; grid-row: ${y + 1} / span ${span};`; const settingsPanel = edit && hasSettings ? '' : ''; return `
${edit ? editBarHtml(it, i, hasSettings) : ''}${settingsPanel}
`; } async function profileListHtml() { const profielen = await listProfiles(); const actief = profielen.find(p => p.isActive); const systeem = !!(actief && actief.isSystem); const items = profielen.map(p => ``).join(''); const knop = (act, icoon, label, uit = false) => ``; return `
${items}
${knop('profile-new', PLUS, t('dashboard.profileNew'))} ${knop('profile-rename', PENCIL, systeem ? t('dashboard.profileSystemHint') : t('dashboard.profileRename'), systeem)} ${knop('profile-delete', TRASH, systeem ? t('dashboard.profileRestart') : t('dashboard.profileDelete'))}
`; } const itemLabelHtml = c => c.labelHtml || esc(c.label); async function toolsHtml() { return profileListHtml(); } function mobileAddHtml(catalog) { const items = catalog.length ? catalog.map((c, i) => ``).join('') : `
${t('dashboard.addNone')}
`; return `
${t('dashboard.add')}
${items}
`; } export async function renderDashboard(host, opts = {}) { if (!host) return; latestOpts.set(host, opts); return draw(host, opts); } const latestOpts = new WeakMap(); const drawGen = new WeakMap(); const rerender = host => draw(host, latestOpts.get(host) || {}); async function draw(host, opts) { const gen = (drawGen.get(host) || 0) + 1; drawGen.set(host, gen); const stale = () => drawGen.get(host) !== gen; const renderers = opts.renderers || {}; const edit = !!opts.edit; const toolbarHost = opts.toolbarHost || null; const layout = await readLayout(); const catalog = edit && typeof opts.catalog === 'function' ? await opts.catalog(layout) : []; if (stale()) return; const hasSettings = type => !!(opts.settings && typeof opts.settings[type] === 'function'); teardownSettingsOutside(); const prevBodies = new Map(); host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(c => { const b = c.querySelector('.dashboard-body'); if (b && c.dataset.inst) prevBodies.set(c.dataset.inst, b); }); const freshMount = prevBodies.size === 0 && layout.length > 0; const holdHeight = host.getBoundingClientRect().height; if (holdHeight > 0) host.style.minHeight = holdHeight + 'px'; const order = layout.map((_, i) => i).sort((a, b) => (layout[a].y - layout[b].y) || (layout[a].x - layout[b].x)); const room = edit ? `` : ''; closeAddPop(); host.innerHTML = `
${order.map(i => cellHtml(layout[i], i, edit, hasSettings(layout[i].type))).join('')}${room}
${edit ? mobileAddHtml(catalog) : ''}`; const clickHandler = edit ? editClickHandler(host, layout, catalog, opts) : null; if (toolbarHost) { const tools = edit ? await toolsHtml() : ''; if (stale()) return; toolbarHost.innerHTML = tools; toolbarHost.onclick = edit ? clickHandler : null; } host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(cell => { const it = layout[Number(cell.dataset.cell)]; if (!it) return; const render = renderers[it.type]; if (typeof render !== 'function') { cell.remove(); return; } const prev = prevBodies.get(it.instId); if (prev) { cell.querySelector('.dashboard-body').replaceWith(prev); prevBodies.delete(it.instId); return; } const body = cell.querySelector('.dashboard-body'); try { render(body, it); } catch (e) { body.innerHTML = errCard(e); } }); host.onclick = clickHandler; host.onchange = edit ? editChangeHandler(host, layout, opts) : null; if (edit) setupDrag(host, layout, opts); if (edit) setupAddBar(host, layout, catalog, opts); teardownEditExit(); if (typeof opts.onEdit === 'function') { if (edit) setupEditExit(host, opts); else setupLongPress(host, opts); } if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; } if (freshMount) { const g = host.querySelector('.dashboard-grid'); if (g) { g.__freshSettle = true; g.classList.add('is-loading'); g.insertAdjacentHTML('beforebegin', skeletonHtml(layout)); loading.sizeSkeleton(g.parentElement); masonryRevealTimer = setTimeout(() => revealGrid(g), DASHBOARD.REVEAL_MAX_MS); } } setupMasonry(host, layout, opts); if (holdHeight > 0) host.style.minHeight = ''; } let settingsOutsideHandler = null; function teardownSettingsOutside() { if (settingsOutsideHandler) { document.removeEventListener('mousedown', settingsOutsideHandler, true); settingsOutsideHandler = null; } } function closeSettingsPanel(panel) { if (!panel) return; panel.setAttribute('hidden', ''); panel.innerHTML = ''; const cell = panel.closest('.dashboard-cell'); if (cell) cell.classList.remove('is-settings-open'); teardownSettingsOutside(); } function editClickHandler(host, layout, catalog, opts) { return async e => { const btn = e.target.closest('[data-act]'); if (!btn) return; const act = btn.dataset.act; if (act === 'settings') { const cell = btn.closest('.dashboard-cell'); const panel = cell && cell.querySelector('.dash-settings'); if (!panel) return; const wasOpen = !panel.hasAttribute('hidden'); host.querySelectorAll('.dash-settings:not([hidden])').forEach(p => { if (p !== panel) closeSettingsPanel(p); }); if (wasOpen) { closeSettingsPanel(panel); return; } const it = layout[Number(btn.dataset.i)]; const fn = it && opts.settings && opts.settings[it.type]; if (typeof fn !== 'function') return; const commit = (o = {}) => { if (o && o.resize) it.h = 0; writeLayout(layout); const body = cell.querySelector('.dashboard-body'); const render = (opts.renderers || {})[it.type]; if (body && typeof render === 'function') { try { render(body, it); } catch (e) { body.innerHTML = errCard(e); } } if (o && o.resize) { cell.classList.remove('is-sized'); const grid = host.querySelector('.dashboard-grid'); if (grid) armPersist(host, grid, layout, opts); } }; panel.innerHTML = ''; fn(panel, it, commit); panel.removeAttribute('hidden'); cell.classList.add('is-settings-open'); teardownSettingsOutside(); settingsOutsideHandler = ev => { if (panel.contains(ev.target)) return; if (ev.target.closest('[data-act="settings"]')) return; closeSettingsPanel(panel); }; document.addEventListener('mousedown', settingsOutsideHandler, true); return; } if (act === 'add') { const c = catalog[Number(btn.dataset.idx)]; if (!c || !DASHBOARD.WIDGETS[c.type]) return; const inst = newInstance(c); inst.x = 0; inst.y = layoutBottom(layout); layout.push(inst); await writeLayout(layout); await rerender(host); return; } if (act.startsWith('profile-')) { await profileAction(host, opts, act, btn); return; } const i = Number(btn.dataset.i); const it = layout[i]; if (!it) return; if (act === 'remove') layout.splice(i, 1); else return; await writeLayout(layout); await rerender(host); }; } async function profileAction(host, opts, act, btn) { const huidig = await activeProfile(); try { if (act === 'profile-pick') { const id = Number(btn.dataset.id); if (!Number.isInteger(id) || id === huidig.id) return; await activateProfile(id); } else if (act === 'profile-new') { const res = await dialog.form({ title: t('dashboard.profileNew'), fields: [{ key: 'name', label: t('dashboard.profileNewPrompt'), value: '' }], confirmLabel: t('common.create'), }); if (!res || !String(res.name || '').trim()) return; await activateProfile(await createProfile({ name: res.name })); } else if (act === 'profile-rename') { if (huidig.isSystem) return; const res = await dialog.form({ title: t('dashboard.profileRename'), fields: [{ key: 'name', label: t('dashboard.profileRenamePrompt'), value: huidig.name }], confirmLabel: t('common.rename'), }); if (!res || !String(res.name || '').trim()) return; await renameProfile(huidig.id, { name: res.name }); } else if (act === 'profile-delete') { const vraag = huidig.isSystem ? t('dashboard.profileRestartConfirm') : t('dashboard.profileDeleteConfirm'); if (!(await dialog.confirm({ message: vraag, danger: true }))) return; await deleteProfile(huidig.id); } else return; } catch (err) { dialog.alert({ title: t('dialog.error'), message: err.message }); } host.innerHTML = ''; await rerender(host); } function editChangeHandler(host, layout, opts) { return async e => { const sel = e.target.closest('select[data-act="width"]'); if (!sel || !host.contains(sel)) return; const it = layout[Number(sel.dataset.i)]; if (!it) return; it.w = clampSpan(sel.value); it.h = 0; await writeLayout(layout); await rerender(host); }; } const MOBILE_QUERY = '(max-width:768px)'; let addPop = null; function addPopOutside(e) { if (addPop && !addPop.contains(e.target) && !e.target.closest('.dash-add-bar')) closeAddPop(); } function closeAddPop() { if (!addPop) return; addPop.remove(); addPop = null; document.removeEventListener('mousedown', addPopOutside, true); } function setupAddBar(host, layout, catalog, opts) { const grid = host.querySelector('.dashboard-grid'); if (!grid) return; const bar = document.createElement('div'); bar.className = 'dash-add-bar'; bar.hidden = true; bar.innerHTML = `${PLUS}`; grid.appendChild(bar); let run = null; const cellAt = (clientX, clientY) => { const gr = grid.getBoundingClientRect(); const cs = getComputedStyle(grid); const colGap = parseFloat(cs.columnGap) || 0; const rowGap = parseFloat(cs.rowGap) || 0; const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS; return { col: Math.floor((clientX - gr.left) / (colW + colGap)), row: Math.floor((clientY - gr.top) / (stepPx() + rowGap)), }; }; const hide = () => { if (!addPop) { bar.hidden = true; run = null; } }; const follow = (target, clientX, clientY) => { if (addPop || grid.classList.contains('is-dragging-active') || matchMedia(MOBILE_QUERY).matches) return; if (target.closest('.dash-add-bar')) return; if (target.closest('.dashboard-cell')) { hide(); return; } const { col, row } = cellAt(clientX, clientY); const r = freeRunAt(layout, col, row, spanSteps); if (!r) { hide(); return; } run = r; bar.style.gridColumn = `${r.x + 1} / span ${r.w}`; bar.style.gridRow = `${r.y + 1} / span 1`; bar.hidden = false; }; grid.addEventListener('pointermove', e => follow(e.target, e.clientX, e.clientY)); grid.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') follow(e.target, e.clientX, e.clientY); }); grid.addEventListener('pointerleave', hide); bar.addEventListener('click', () => { if (run) openAddPop(host, layout, catalog, opts, run, bar); }); } function openAddPop(host, layout, catalog, opts, run, bar) { closeAddPop(); const items = fittingCatalog(catalog, run); const pop = document.createElement('div'); pop.className = 'dashboard-add-menu dash-add-pop is-open'; pop.innerHTML = items.length ? items.map((c, i) => ``).join('') : `
${esc(catalog.length ? t('dashboard.addNoFit') : t('dashboard.addNone'))}
`; document.body.appendChild(pop); addPop = pop; const r = bar.getBoundingClientRect(); const w = pop.offsetWidth, h = pop.offsetHeight; const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2)); const top = r.bottom + 6 + h > window.innerHeight ? Math.max(8, r.top - 6 - h) : r.bottom + 6; pop.style.left = left + 'px'; pop.style.top = top + 'px'; document.addEventListener('mousedown', addPopOutside, true); pop.addEventListener('click', async e => { const btn = e.target.closest('[data-idx]'); const c = btn && items[Number(btn.dataset.idx)]; if (!c) return; closeAddPop(); const inst = newInstance(c); Object.assign(inst, placeInRun(run, inst.w)); layout.push(inst); makeRoom(layout, [layout.length - 1]); await writeLayout(layout); await rerender(host); }); } let swallowClick = false; let swallowBound = false; function setupLongPress(host, opts) { const grid = host.querySelector('.dashboard-grid'); if (!grid) return; if (!swallowBound) { swallowBound = true; window.addEventListener('click', e => { if (!swallowClick) return; swallowClick = false; e.preventDefault(); e.stopImmediatePropagation(); }, true); window.addEventListener('pointerdown', () => { swallowClick = false; }, true); } grid.addEventListener('pointerdown', e => { swallowClick = false; if (e.pointerType !== 'mouse' || e.button !== 0) return; if (!e.target.closest('.dashboard-cell')) return; const x0 = e.clientX, y0 = e.clientY; const stop = () => { clearTimeout(timer); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', stop); window.removeEventListener('pointercancel', stop); }; const onMove = ev => { if (Math.hypot(ev.clientX - x0, ev.clientY - y0) > DASHBOARD.LONG_PRESS_SLOP_PX) stop(); }; const timer = setTimeout(() => { stop(); swallowClick = true; opts.onEdit(true); }, DASHBOARD.LONG_PRESS_MS); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', stop); window.addEventListener('pointercancel', stop); }); } let editExitHandlers = null; function teardownEditExit() { if (!editExitHandlers) return; document.removeEventListener('keydown', editExitHandlers.key); document.removeEventListener('click', editExitHandlers.click); editExitHandlers = null; } const EDIT_INSIDE = '.dashboard-cell, .dash-add-bar,.dashboard-add-menu, .dash-add-mobile, #page-tools, #nav-tools, #sidebar, .dialog-overlay'; function setupEditExit(host, opts) { const done = () => { teardownEditExit(); if (host.isConnected) opts.onEdit(false); }; const key = e => { if (e.key !== 'Escape') return; if (!host.isConnected) { teardownEditExit(); return; } if (e.defaultPrevented || !e.target.isConnected || document.querySelector('.dialog-overlay')) return; const panel = host.querySelector('.dash-settings:not([hidden])'); if (panel) { closeSettingsPanel(panel); return; } if (addPop) { closeAddPop(); return; } done(); }; const click = e => { if (!e.target.isConnected) return; if (e.target.closest(EDIT_INSIDE)) return; done(); }; editExitHandlers = { key, click }; document.addEventListener('keydown', key); document.addEventListener('click', click); } function setupDrag(host, layout, opts) { const grid = host.querySelector('.dashboard-grid'); if (!grid) return; grid.addEventListener('pointerdown', e => { if (e.button != null && e.button !== 0) return; const handle = e.target.closest('.dash-edit-grip'); if (!handle || !grid.contains(handle)) return; const fromCell = handle.closest('.dashboard-cell'); if (!fromCell) return; e.preventDefault(); startDrag({ host, grid, layout, opts, fromCell, fromIdx: Number(fromCell.dataset.cell), startX: e.clientX, startY: e.clientY }); }); } function swallowNextClick() { const eat = e => { e.preventDefault(); e.stopImmediatePropagation(); off(); }; const off = () => { clearTimeout(timer); window.removeEventListener('click', eat, true); window.removeEventListener('pointerdown', off, true); }; const timer = setTimeout(off, 300); window.addEventListener('click', eat, true); window.addEventListener('pointerdown', off, true); } function startDrag({ host, grid, layout, opts, fromCell, fromIdx, startX, startY }) { const it = layout[fromIdx]; if (!it) return; const w = clampSpan(it.w); const h = spanSteps(it); grid.classList.add('is-dragging-active'); fromCell.classList.add('is-dragging'); const srcRect = fromCell.getBoundingClientRect(); const grabX = startX - srcRect.left, grabY = startY - srcRect.top; const srcInner = fromCell.querySelector('.dashboard-body > *'); const ghost = document.createElement('div'); ghost.className = 'dashboard-drag-ghost'; ghost.style.width = srcRect.width + 'px'; ghost.style.left = srcRect.left + 'px'; ghost.style.top = srcRect.top + 'px'; if (srcInner) ghost.appendChild(srcInner.cloneNode(true)); document.body.appendChild(ghost); const moveGhost = (x, y) => { ghost.style.left = (x - grabX) + 'px'; ghost.style.top = (y - grabY) + 'px'; }; const preview = document.createElement('div'); preview.className = 'dashboard-drop-preview'; grid.appendChild(preview); let target = null; let swapCell = null; const cellByIdx = idx => grid.querySelector('.dashboard-cell[data-cell="' + idx + '"]'); const clearSwap = () => { if (swapCell) { swapCell.classList.remove('is-drop-swap'); swapCell = null; } }; const snapCell = (clientX, clientY) => { const gr = grid.getBoundingClientRect(); const cs = getComputedStyle(grid); const colGap = parseFloat(cs.columnGap) || 0; const rowGap = parseFloat(cs.rowGap) || 0; const step = stepPx(); const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS; let x = Math.round(((clientX - grabX) - gr.left) / (colW + colGap)); x = Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, x)); const y = Math.max(0, Math.round(((clientY - grabY) - gr.top) / (step + rowGap))); return { x, y }; }; const computeTarget = (clientX, clientY) => { let over = document.elementFromPoint(clientX, clientY); over = over && over.closest ? over.closest('.dashboard-cell') : null; if (over && grid.contains(over) && Number(over.dataset.cell) !== fromIdx) { return { mode: 'swap', overIdx: Number(over.dataset.cell) }; } const s = snapCell(clientX, clientY); return { mode: 'insert', x: s.x, y: s.y }; }; const showTarget = tg => { if (tg.mode === 'swap') { preview.style.display = 'none'; const cell = cellByIdx(tg.overIdx); if (swapCell && swapCell !== cell) clearSwap(); if (cell) { swapCell = cell; cell.classList.add('is-drop-swap'); } } else { clearSwap(); preview.style.display = ''; preview.style.gridColumn = (tg.x + 1) + ' / span ' + w; preview.style.gridRow = (tg.y + 1) + ' / span ' + h; } }; const onMove = ev => { moveGhost(ev.clientX, ev.clientY); target = computeTarget(ev.clientX, ev.clientY); showTarget(target); }; moveGhost(startX, startY); target = computeTarget(startX, startY); showTarget(target); const finish = async commit => { if (commit) swallowNextClick(); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onCancel); clearSwap(); preview.remove(); ghost.remove(); grid.classList.remove('is-dragging-active'); fromCell.classList.remove('is-dragging'); if (!commit || !target) return; if (target.mode === 'swap') { const b = layout[target.overIdx]; if (!b || target.overIdx === fromIdx) return; const ax = it.x, ay = it.y; it.x = b.x; it.y = b.y; b.x = ax; b.y = ay; if (rectsOverlap(it, b)) makeRoom(layout, [fromIdx]); else makeRoom(layout, [fromIdx, target.overIdx]); } else { if (target.x === it.x && target.y === it.y) return; it.x = target.x; it.y = target.y; makeRoom(layout, [fromIdx]); } await writeLayout(layout); await rerender(host); }; const onUp = () => finish(true); const onCancel = () => finish(false); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onCancel); } function packGrid(grid) { if (!grid) return; const gap = parseFloat(getComputedStyle(grid).columnGap) || 0; const rows = Math.round(stepPx()) + 'px'; const rowGap = Math.round(gap) + 'px'; if (grid.style.gridAutoRows !== rows) grid.style.gridAutoRows = rows; if (grid.style.rowGap !== rowGap) grid.style.rowGap = rowGap; } function scheduleMasonry(grid) { if (!grid || grid.__packQueued) return; grid.__packQueued = true; requestAnimationFrame(() => { grid.__packQueued = false; packGrid(grid); }); } function setupMasonry(host, layout, opts) { const grid = host.querySelector('.dashboard-grid'); if (!grid) return; packGrid(grid); if (typeof ResizeObserver !== 'undefined') { if (masonryRO) masonryRO.disconnect(); masonryRO = new ResizeObserver(() => { scheduleMasonry(grid); armPersist(host, grid, layout, opts); }); grid.querySelectorAll(':scope > .dashboard-cell').forEach(c => masonryRO.observe(c)); } if (typeof MutationObserver !== 'undefined') { if (masonryMO) masonryMO.disconnect(); if (growCheckTimer) { clearTimeout(growCheckTimer); growCheckTimer = null; } masonryMO = new MutationObserver(() => scheduleGrowCheck(host, grid, layout, opts)); grid.querySelectorAll(':scope > .dashboard-cell > .dashboard-body') .forEach(b => masonryMO.observe(b, { childList: true, subtree: true, characterData: true })); } if (!masonryResizeBound) { masonryResizeBound = true; window.addEventListener('resize', () => { const g = document.querySelector('.dashboard-grid'); if (g) scheduleMasonry(g); }); } armPersist(host, grid, layout, opts); } function armPersist(host, grid, layout, opts) { if (masonrySettleTimer) clearTimeout(masonrySettleTimer); masonrySettleTimer = setTimeout(() => persistSettled(host, grid, layout, opts), DASHBOARD.SETTLE_MS); } function scheduleGrowCheck(host, grid, layout, opts) { if (masonrySettleTimer || growCheckTimer) return; growCheckTimer = setTimeout(() => { growCheckTimer = null; persistSettled(host, grid, layout, opts); }, DASHBOARD.SETTLE_MS); } async function persistSettled(host, grid, layout, opts) { masonrySettleTimer = null; if (!grid.isConnected) return; if (!grid.getBoundingClientRect().height) { if (grid.querySelector(':scope > .dashboard-cell')) armPersist(host, grid, layout, opts); return; } const step = stepPx(); const gap = parseFloat(getComputedStyle(grid).columnGap) || 0; const minSteps = DASHBOARD.MIN_H; const GROW_TOL = 4; const grown = []; grid.querySelectorAll(':scope > .dashboard-cell').forEach(cell => { const idx = Number(cell.dataset.cell); const it = layout[idx]; if (!it) return; const bodyEl = cell.querySelector('.dashboard-body'); const content = Math.round((bodyEl || cell).scrollHeight); const wanted = Math.max(minSteps, stepsForPx(content, step, gap)); if (it.h <= 0) { if (wanted !== it.h) { it.h = wanted; grown.push(idx); } } else if (content > pxForSteps(it.h, step, gap) + GROW_TOL && wanted > it.h) { it.h = wanted; grown.push(idx); } }); if (!grown.length) { revealGrid(grid); return; } const fresh = grid.__freshSettle; grid.__freshSettle = false; if (fresh) packLayout(layout); else makeRoom(layout, grown); await writeLayout(layout); try { await setPref(LAYOUT_SCOPE, MODEL_KEY, DASHBOARD.HEIGHT_MODEL); } catch { } await rerender(host); } function revealGrid(grid) { if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; } if (!grid || !grid.classList.contains('is-loading')) return; const host = grid.parentElement; const skel = host && host.querySelector(':scope > .dashboard-skeleton'); if (skel) skel.remove(); grid.classList.remove('is-loading'); } function skeletonHtml(layout) { const step = stepPx(); const cells = layout.map(it => { const steps = it.h > 0 ? it.h : DASHBOARD.SKELETON_STEPS; const h = Math.round(steps * step); return `
${loading.card()}
`; }).join(''); return ``; }