Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -0,0 +1,805 @@
|
||||
// Generated, do not edit. sha256:890b0e46a8d4907e79564933d64186ce26573ba305bedb6fd4fa2f911700fadc
|
||||
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 `<div class="card"><div class="t-body-sm t-muted">\u26A0 ${esc((e && e.message) || e)}</div></div>`;
|
||||
}
|
||||
|
||||
function editBarHtml(it, i, hasSettings) {
|
||||
let opts = '';
|
||||
for (let n = DASHBOARD.MIN_SPAN; n <= DASHBOARD.MAX_SPAN; n++) opts += `<option value="${n}"${n === it.w ? ' selected' : ''}>${n}</option>`;
|
||||
const widthSel = `<label class="dash-edit-wsel-wrap" title="${t('dashboard.width')}"><select class="dash-edit-wsel" data-act="width" data-i="${i}" aria-label="${t('dashboard.width')}">${opts}</select></label>`;
|
||||
const cfgBtn = hasSettings
|
||||
? `<button type="button" class="dash-edit-cfg" data-act="settings" data-i="${i}" title="${t('dashboard.settings')}" aria-label="${t('dashboard.settings')}">${ADJUST}</button>`
|
||||
: '';
|
||||
return `<div class="dash-edit-bar">
|
||||
<span class="dash-edit-grip" data-drag="${i}" role="button" tabindex="-1" title="${t('dashboard.reorder')}" aria-label="${t('dashboard.reorder')}">${GRIP}</span>
|
||||
${widthSel}
|
||||
${cfgBtn}
|
||||
<button type="button" class="dash-edit-x" data-act="remove" data-i="${i}" title="${t('dashboard.remove')}" aria-label="${t('dashboard.remove')}">${CLOSE}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 ? '<div class="dash-settings" data-settings hidden></div>' : '';
|
||||
return `<div class="${cls}" data-cell="${i}" data-inst="${esc(it.instId)}" data-w="${it.w}" style="${place}">${edit ? editBarHtml(it, i, hasSettings) : ''}${settingsPanel}<div class="dashboard-body"></div></div>`;
|
||||
}
|
||||
|
||||
async function profileListHtml() {
|
||||
const profielen = await listProfiles();
|
||||
const actief = profielen.find(p => p.isActive);
|
||||
const systeem = !!(actief && actief.isSystem);
|
||||
const items = profielen.map(p => `<button type="button" role="option" class="dash-profile-item${p.isActive ? ' is-active' : ''}"
|
||||
aria-selected="${p.isActive ? 'true' : 'false'}" data-act="profile-pick" data-id="${p.id}">${esc(p.name)}</button>`).join('');
|
||||
const knop = (act, icoon, label, uit = false) =>
|
||||
`<button type="button" class="dash-profile-btn" data-act="${act}" title="${esc(label)}" aria-label="${esc(label)}"${uit ? ' disabled' : ''}>${icoon}</button>`;
|
||||
return `<div class="dash-profiles">
|
||||
<div class="dash-profile-list" role="listbox" aria-label="${esc(t('dashboard.profile'))}">${items}</div>
|
||||
<div class="dash-profile-actions">
|
||||
${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'))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const itemLabelHtml = c => c.labelHtml || esc(c.label);
|
||||
|
||||
async function toolsHtml() {
|
||||
return profileListHtml();
|
||||
}
|
||||
|
||||
function mobileAddHtml(catalog) {
|
||||
const items = catalog.length
|
||||
? catalog.map((c, i) => `<button type="button" class="dashboard-add-item" data-act="add" data-idx="${i}">${itemLabelHtml(c)}</button>`).join('')
|
||||
: `<div class="dashboard-add-empty">${t('dashboard.addNone')}</div>`;
|
||||
return `<div class="dash-add-mobile"><div class="t-label t-label--ter">${t('dashboard.add')}</div>${items}</div>`;
|
||||
}
|
||||
|
||||
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 ? `<div class="dash-add-room" aria-hidden="true" style="grid-column: 1 / -1; grid-row: ${layoutBottom(layout) + 1} / span 2;"></div>` : '';
|
||||
closeAddPop();
|
||||
host.innerHTML = `<div class="dashboard-grid${edit ? ' is-editing' : ''}">${order.map(i => cellHtml(layout[i], i, edit, hasSettings(layout[i].type))).join('')}${room}</div>${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: '' }],
|
||||
});
|
||||
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 }],
|
||||
});
|
||||
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 = `<span class="dash-add-bar-plus" role="button" title="${esc(t('dashboard.add'))}" aria-label="${esc(t('dashboard.add'))}">${PLUS}</span>`;
|
||||
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) => `<button type="button" class="dashboard-add-item" data-idx="${i}">${itemLabelHtml(c)}</button>`).join('')
|
||||
: `<div class="dashboard-add-empty">${esc(catalog.length ? t('dashboard.addNoFit') : t('dashboard.addNone'))}</div>`;
|
||||
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 `<div class="dashboard-skel-cell" style="grid-column: span ${it.w}; min-height:${h}px">${loading.card()}</div>`;
|
||||
}).join('');
|
||||
return `<div class="dashboard-skeleton" aria-hidden="true"><div class="dashboard-skel-grid">${cells}</div></div>`;
|
||||
}
|
||||
Reference in new issue
Block a user