Live-ghost column drag; "only" app filter; disk size / free space filters

Custom View column drag rewritten with pointer events (replacing native
drag-and-drop): grab a column by its grip or anywhere on its header (a
click still sorts); the real column slides into the slot it would land
in as a translucent striped ghost while a label follows the pointer.
Only the horizontal position decides the slot. Esc cancels; data
refreshes wait until the drop; wide tables scroll near the edges.

Filters:
- List columns (software, tags): "Has any ticked" / "Has only ticked"
  toggle - e.g. devices missing only Adobe Acrobat Pro
- Space columns: filter by disk size (221 GB, 442 GB, no reading) plus a
  free-space rule (any / at least N GB / less than N GB, default 20); sort
  by disk size or by free space (numeric, no reading last); header clicks
  cancel these sorts
- Menu controls no longer shrink when the menu is height-limited
This commit is contained in:
Johan Sandoval committed 2026-10-06 18:20:00 +00:00
1 parent 973ff592a4
commit 50c2a18a68
3 files changed
+277 -92

No files matched your search

+140 -17
View File
@@ -7,7 +7,9 @@
* The value list only shows values from rows that pass the OTHER columns' filters, filters
* on several columns combine (AND), and a filtered column's button shows a funnel.
* List-type columns (Tags, Missing/Specialty Software) are split so each tag or app is
* its own checkbox; a row matches if it has any of the checked items.
* its own checkbox; a row matches if it has ANY of the ticked items, or with "Has only
* ticked" if it has nothing BUT ticked items. Free-space columns ("78 GB / 221 GB") filter
* by disk size plus a free-space threshold, and sort by disk size or by free space.
*
* It plugs into the dashboard's existing filter passes (applyFilters, filterSoftwareTab,
* filterCustomTab, refreshStackTable): those run first (global search etc.), then rows
@@ -23,6 +25,8 @@
const LIST_COLUMNS = /^(tags|missing software|specialty software)$/i;
const SKIP_COLUMNS = /(^|\s)apps$/i; // "Apps" button columns have nothing to filter
const BLANK = '(Blanks)';
const NO_READING = '(No reading)'; // space column: offline / cached / not checked
const SPACE_RE = /([\d.]+)\s*GB\s*\/\s*([\d.]+)\s*GB/i;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
const filters = {}; // table key -> { column key -> Set of allowed values }
@@ -111,6 +115,7 @@
return {
idx, th, label,
list: LIST_COLUMNS.test(label),
space: /space/i.test(label),
filterable: !!label && !SKIP_COLUMNS.test(label),
sortable: !!label && !SKIP_COLUMNS.test(label) && !!ad.sortable(th)
};
@@ -120,9 +125,17 @@
const dataRows = table => Array.from(table.tBodies[0] ? table.tBodies[0].rows : [])
.filter(r => !(r.cells.length === 1 && r.cells[0].colSpan > 1));
const rawText = td => norm(td ? (td.getAttribute('data-val') != null ? td.getAttribute('data-val') : td.textContent) : '');
function parseSpace(td) {
const m = rawText(td).match(SPACE_RE);
return m ? { free: parseFloat(m[1]), total: parseFloat(m[2]) } : null;
}
function cellValues(td, col) {
let raw = td ? (td.getAttribute('data-val') != null ? td.getAttribute('data-val') : td.textContent) : '';
raw = norm(raw);
const raw = rawText(td);
if (col.space) { // list a space cell under its disk size
const sp = parseSpace(td);
return [sp ? `${sp.total} GB` : NO_READING];
}
if (!raw) return [BLANK];
if (col.list) {
const parts = raw.split(',').map(norm).filter(Boolean);
@@ -146,7 +159,15 @@
if (k === exceptKey) continue;
const allowed = set[k];
if (!allowed) continue;
if (!cellValues(tr.cells[col.idx], col).some(v => allowed.has(v))) return false;
const td = tr.cells[col.idx];
if (col.space && allowed.free) {
const sp = parseSpace(td);
if (!sp) return false;
if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false;
}
const vals = cellValues(td, col);
if (col.list && allowed.only) { if (!vals.every(v => allowed.has(v))) return false; }
else if (!vals.some(v => allowed.has(v))) return false;
}
return true;
}
@@ -160,7 +181,7 @@
table.classList.add('xf-table');
const cols = columnsOf(table, ad);
if (ad.native && nativeSort[ad.key] && nativeSort[ad.key].dir !== 'none') nativeSortRows(table, cols, nativeSort[ad.key]);
if (nativeSort[ad.key] && nativeSort[ad.key].dir !== 'none') nativeSortRows(table, cols, nativeSort[ad.key]);
let visible = 0;
for (const tr of dataRows(table)) {
@@ -186,7 +207,13 @@
if (!col) return;
const body = table.tBodies[0];
const rows = dataRows(table);
const num = tr => { const sp = parseSpace(tr.cells[s.col]); return sp ? sp[s.by] : null; };
rows.sort((a, b) => {
if (s.by) { // disk size / free space: no reading always last
const x = num(a), y = num(b);
if (x === null || y === null) return (x === null) - (y === null);
return s.dir === 'desc' ? y - x : x - y;
}
const r = collator.compare(norm(a.cells[s.col] ? a.cells[s.col].textContent : ''), norm(b.cells[s.col] ? b.cells[s.col].textContent : ''));
return s.dir === 'desc' ? -r : r;
});
@@ -215,7 +242,7 @@
}
// Like Excel's button: funnel when filtered, an arrow when sorted, otherwise a caret
const on = activeKeys.has(ad.colKey(c.idx));
const dir = ad.sortDir(c.idx);
const dir = sortDirOf(ad, c.idx);
const state = on ? 'filtered' : (dir === 'asc' || dir === 'desc') ? dir : 'plain';
btn.classList.toggle('xf-on', on);
btn.classList.toggle('xf-sorted', state === 'asc' || state === 'desc');
@@ -261,7 +288,7 @@
if (!c.filterable && !c.sortable) return;
const on = activeKeys.has(ad.colKey(c.idx));
const n = on ? filters[ad.key][ad.colKey(c.idx)].size : 0;
const dir = ad.sortDir(c.idx);
const dir = sortDirOf(ad, c.idx);
const arrow = dir === 'asc' ? ' &uarr;' : dir === 'desc' ? ' &darr;' : '';
parts.push(`<button type="button" class="xf-chip${on ? ' xf-on' : ''}" data-idx="${c.idx}">${on ? FUNNEL_SVG : ''}<span></span>${on ? ` <b>${n}</b>` : ''}${arrow}${CARET_SVG}</button>`);
});
@@ -284,6 +311,23 @@
}
}
// Effective sort direction of a column: a size/free-space sort overrides the table's own
function sortDirOf(ad, i) {
const ns = nativeSort[ad.key];
if (ns && ns.dir !== 'none' && (ns.by || ad.native)) return ns.col === i ? ns.dir : 'none';
return ad.sortDir(i);
}
function applySort(ad, i, dir, by) {
if (by) { // numeric sort we do ourselves, after each render
nativeSort[ad.key] = { col: i, dir, by };
if (ad.native) ad.rerun();
else ad.sort(-1, 'none'); // re-render without the table's own sort
return;
}
if (!ad.native) delete nativeSort[ad.key];
ad.sort(i, dir);
}
function groupHeaderFor(table) {
for (let el = table.parentElement, depth = 0; el && depth < 3; el = el.parentElement, depth++) {
const gh = el.querySelector(':scope > .group-header');
@@ -335,8 +379,9 @@
for (const v of cellValues(tr.cells[idx], col)) { if (v === BLANK) hasBlank = true; else seen.add(v); }
}
// "None" (nothing missing) first so the easy devices are one click away; (Blanks) last
const values = Array.from(seen).filter(v => v !== 'None').sort(collator.compare);
const values = Array.from(seen).filter(v => v !== 'None' && v !== NO_READING).sort(collator.compare);
if (seen.has('None')) values.unshift('None');
if (seen.has(NO_READING)) values.push(NO_READING);
if (hasBlank) values.push(BLANK);
menuCtx = { table, idx };
@@ -357,13 +402,26 @@
b.disabled = !!disabled; b.onclick = onClick;
menu.appendChild(b);
};
if (col.sortable) {
const dir = ad.sortDir(idx);
item(SORT_ASC_SVG, 'Sort A to Z', () => { closeMenu(); ad.sort(idx, dir === 'asc' ? 'none' : 'asc'); });
item(SORT_DESC_SVG, 'Sort Z to A', () => { closeMenu(); ad.sort(idx, dir === 'desc' ? 'none' : 'desc'); });
const marks = menu.querySelectorAll('.xf-item');
if (dir === 'asc') marks[0].classList.add('xf-checked');
if (dir === 'desc') marks[1].classList.add('xf-checked');
if (col.space) {
// Space columns sort by a number, not by text
const ns = nativeSort[ad.key];
const cur = ns && ns.col === idx && ns.dir !== 'none' ? `${ns.by}-${ns.dir}` : '';
const opt = (svg, text, by, dir) => {
const id = `${by}-${dir}`;
item(svg, text, () => { closeMenu(); applySort(ad, idx, cur === id ? 'none' : dir, by); });
if (cur === id) menu.lastChild.classList.add('xf-checked');
};
opt(SORT_ASC_SVG, 'Sort by disk size: smallest first', 'total', 'asc');
opt(SORT_DESC_SVG, 'Sort by disk size: largest first', 'total', 'desc');
opt(SORT_ASC_SVG, 'Sort by free space: least first', 'free', 'asc');
opt(SORT_DESC_SVG, 'Sort by free space: most first', 'free', 'desc');
menu.appendChild(el('div', 'xf-sep'));
} else if (col.sortable) {
const dir = sortDirOf(ad, idx);
item(SORT_ASC_SVG, 'Sort A to Z', () => { closeMenu(); applySort(ad, idx, dir === 'asc' ? 'none' : 'asc'); });
if (dir === 'asc') menu.lastChild.classList.add('xf-checked');
item(SORT_DESC_SVG, 'Sort Z to A', () => { closeMenu(); applySort(ad, idx, dir === 'desc' ? 'none' : 'desc'); });
if (dir === 'desc') menu.lastChild.classList.add('xf-checked');
menu.appendChild(el('div', 'xf-sep'));
}
@@ -374,7 +432,58 @@
}, !current);
const search = el('input', 'xf-search'); search.type = 'search'; search.placeholder = 'Search';
menu.appendChild(search);
if (!col.space) menu.appendChild(search);
// Free-space rule (space columns)
let freeRule = null;
if (col.space) {
const box = el('div', 'xf-section');
box.appendChild(el('div', 'xf-section-title', 'Free space'));
const gbIn = el('input', 'xf-num'); gbIn.type = 'number'; gbIn.min = '0'; gbIn.step = '1';
gbIn.value = String(current && current.free ? current.free.gb : 20);
const radios = [];
const radio = (value, labelFn) => {
const row = el('label', 'xf-radio');
const r = el('input'); r.type = 'radio'; r.name = 'xf-free'; r.value = value;
r.checked = (current && current.free ? current.free.op : 'any') === value;
const span = el('span', null, labelFn());
row.appendChild(r); row.appendChild(span);
box.appendChild(row);
radios.push({ r, span, labelFn });
};
radio('any', () => 'Any amount');
radio('ge', () => `At least ${gbIn.value || 0} GB free`);
radio('lt', () => `Less than ${gbIn.value || 0} GB free`);
const thr = el('label', 'xf-thr');
thr.appendChild(el('span', null, 'Threshold'));
thr.appendChild(gbIn); thr.appendChild(el('span', null, 'GB'));
box.appendChild(thr);
gbIn.oninput = () => radios.forEach(x => { x.span.textContent = x.labelFn(); });
menu.appendChild(box);
menu.appendChild(el('div', 'xf-section-title xf-list-title', 'Disk size'));
freeRule = () => {
const sel = radios.find(x => x.r.checked);
const op = sel ? sel.r.value : 'any';
return op === 'any' ? null : { op, gb: Math.max(0, parseFloat(gbIn.value) || 0) };
};
}
// List columns: match rows having ANY ticked item, or ONLY ticked items
let onlyToggle = null;
if (col.list) {
const seg = el('div', 'xf-seg');
seg.title = 'Any: devices with at least one ticked item. Only: devices with nothing except ticked items.';
const mk = (text, only) => {
const b = el('button', 'xf-seg-btn', text); b.type = 'button';
b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); };
if (!!(current && current.only) === only) b.classList.add('on');
seg.appendChild(b);
return b;
};
mk('Has any ticked', false);
onlyToggle = mk('Has only ticked', true);
menu.appendChild(seg);
}
const list = el('div', 'xf-list');
const mkRow = (label, checked, cls) => {
@@ -455,8 +564,12 @@
ok.onclick = () => {
const q = search.value.trim();
const chosen = new Set(visibleOpts().filter(o => o.cb.checked).map(o => o.v));
const free = freeRule ? freeRule() : null;
if (free) chosen.free = free;
if (onlyToggle && onlyToggle.classList.contains('on')) chosen.only = true;
filters[ad.key] = filters[ad.key] || {};
if (!q && chosen.size === values.length) delete filters[ad.key][key]; // everything ticked = no filter
// everything ticked and no extra rule = no filter
if (!q && chosen.size === values.length && !free) delete filters[ad.key][key];
else filters[ad.key][key] = chosen;
closeMenu(); ad.rerun();
};
@@ -602,6 +715,16 @@
customFilters = {};
if (typeof saveCustomState === 'function') saveCustomState();
}
// Clicking a column header uses the dashboard's own sort: drop any size/free-space sort
const before = (name, fn) => {
const orig = window[name];
if (typeof orig !== 'function' || orig.__xfBefore) return;
const w = function () { try { fn.apply(null, arguments); } catch (e) {} return orig.apply(this, arguments); };
w.__xfBefore = true;
window[name] = w;
};
before('toggleSort', g => { delete nativeSort[g === 'software' ? 'software' : 'group-' + g]; });
before('toggleCustomSort', () => { delete nativeSort.custom; });
// Custom View's own filter icon opens the Excel-style menu instead
window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor);
+33 -15
View File
@@ -361,21 +361,39 @@ table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; p
}
.xf-opt.xf-none { font-style: italic; border-bottom: 1px dashed var(--border-light); }
/* Custom View column drag preview (layout.js) */
.col-drag-ghost {
position: fixed; top: -1000px; left: -1000px; z-index: 9999; pointer-events: none;
background: var(--bg-card); color: var(--text-main); border: 2px solid #3498db; border-radius: 6px;
box-shadow: 0 10px 24px rgba(0,0,0,0.45); overflow: hidden; font-size: 12px;
/* Custom View column drag (layout.js): the column moves live as a ghost, a label follows */
#table-custom thead th.custom-th .col-label-row { cursor: grab; }
body.col-dragging, body.col-dragging * { cursor: grabbing !important; user-select: none !important; }
#table-custom .col-ghost {
opacity: 0.55;
background-image: repeating-linear-gradient(45deg, rgba(52,152,219,0.16) 0 6px, transparent 6px 12px) !important;
box-shadow: inset 2px 0 0 #3498db, inset -2px 0 0 #3498db;
}
.col-drag-ghost-head { padding: 7px 10px; font-weight: 700; color: #fff; background: var(--table-header-bg); }
.col-drag-ghost-cell {
padding: 5px 10px; border-top: 1px solid var(--border-light);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
#table-custom thead th.col-ghost { opacity: 0.8; box-shadow: inset 2px 0 0 #3498db, inset -2px 0 0 #3498db, inset 0 2px 0 #3498db; }
.col-drag-float {
position: fixed; z-index: 9999; pointer-events: none; white-space: nowrap;
transform: translate(12px, -50%) rotate(-2deg);
padding: 7px 12px; font-weight: 700; font-size: 13px; color: #fff;
background: var(--table-header-bg); border: 1px solid #3498db; border-radius: 6px;
box-shadow: 0 10px 24px rgba(0,0,0,0.45);
}
#table-custom .col-moving { opacity: 0.35; }
.col-drop-marker {
position: absolute; top: 0; width: 4px; z-index: 30; pointer-events: none;
background: #3498db; border-radius: 2px; box-shadow: 0 0 8px rgba(52,152,219,0.8);
/* Filter menu: sections, free-space rule, any/only toggle */
.xf-section { padding: 2px 12px 6px; }
.xf-section-title { font-size: 0.75em; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-muted); padding: 4px 0; }
.xf-list-title { padding: 4px 12px; }
.xf-radio { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
.xf-thr { display: flex; align-items: center; gap: 8px; padding: 4px 0 0 24px; color: var(--text-muted); font-size: 0.9em; }
.xf-num {
width: 64px; padding: 4px 6px; font: inherit; color: var(--text-main);
background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 4px;
}
/* the single insertion line replaces the per-header highlight */
#table-custom .custom-th.drag-over { box-shadow: none !important; }
.xf-seg { display: flex; margin: 0 12px 6px; border: 1px solid var(--border-color); border-radius: 6px; overflow: hidden; }
.xf-seg-btn {
flex: 1; padding: 5px 6px; cursor: pointer; font: inherit; font-size: 0.85em; white-space: nowrap;
background: transparent; color: var(--text-muted); border: 0;
}
.xf-seg-btn + .xf-seg-btn { border-left: 1px solid var(--border-color); }
.xf-seg-btn.on { background: #3498db; color: #fff; font-weight: 600; }
/* Only the value list shrinks when the menu is height-limited; controls keep their size */
.xf-menu > :not(.xf-list):not(.xf-add):not(.xf-fields) { flex-shrink: 0; }
+104 -60
View File
@@ -142,67 +142,108 @@
});
}
// Custom View column drag: a ghost of the column follows the cursor, the column being
// moved is dimmed, and a line shows exactly where it will land. Adds to the dashboard's
// own drag-and-drop (attachColDragHandlers), which still performs the move.
function enhanceColumnDrag() {
// Custom View column drag (desktop). Grab a column by its grip or anywhere on its header
// (a plain click still sorts). While dragging, the real column slides into the place it
// would land, drawn as a translucent ghost, and a label follows the pointer. Only the
// pointer's horizontal position matters: passing a column's middle moves the ghost past
// it. Release to drop, Esc to cancel. Replaces the browser's native drag-and-drop.
const colDrag = { active: null, pendingRender: false };
function setupColumnDrag() {
const table = document.getElementById('table-custom');
const host = document.getElementById('custom-table-host');
if (!table || !host) return;
const headers = Array.from(table.querySelectorAll('thead th.custom-th'));
let from = null, marker = null;
if (!table || table.dataset.pdrag) return;
table.dataset.pdrag = '1';
const setDimmed = (idx, on) => {
for (const row of table.rows) { const c = row.cells[idx]; if (c) c.classList.toggle('col-moving', on); }
};
const clear = () => {
if (from !== null) setDimmed(from, false);
if (marker) marker.remove();
marker = null; from = null;
};
// Native drag-and-drop on the grips is replaced by the pointer drag below
table.addEventListener('dragstart', e => { if (e.target.closest && e.target.closest('thead')) e.preventDefault(); }, true);
headers.forEach((th, idx) => {
const grip = th.querySelector('.col-grip');
if (!grip || grip.dataset.ghost) return;
grip.dataset.ghost = '1';
grip.addEventListener('dragstart', e => {
from = idx;
const ghost = document.createElement('div');
ghost.className = 'col-drag-ghost';
const head = document.createElement('div');
head.className = 'col-drag-ghost-head';
head.textContent = th.querySelector('.label-text') ? th.querySelector('.label-text').textContent : th.textContent.trim();
ghost.appendChild(head);
Array.from(table.tBodies[0].rows).filter(r => r.style.display !== 'none').slice(0, 7).forEach(r => {
const cell = document.createElement('div');
cell.className = 'col-drag-ghost-cell';
cell.textContent = r.cells[idx] ? r.cells[idx].textContent.trim() : '';
ghost.appendChild(cell);
});
ghost.style.width = Math.min(280, Math.max(140, th.getBoundingClientRect().width)) + 'px';
document.body.appendChild(ghost);
try { e.dataTransfer.setDragImage(ghost, 24, 18); } catch (_) {}
setTimeout(() => ghost.remove(), 0);
setDimmed(idx, true);
});
grip.addEventListener('dragend', clear);
th.addEventListener('dragover', () => {
if (from === null) return;
if (!marker) { marker = document.createElement('div'); marker.className = 'col-drop-marker'; host.appendChild(marker); }
// moveCustomColumn(from, to) lands the column after the target when moving right,
// before it when moving left
const hostBox = host.getBoundingClientRect(), box = th.getBoundingClientRect();
const x = (idx > from ? box.right : box.left) - hostBox.left;
marker.style.left = (x - 2) + 'px';
marker.style.height = table.getBoundingClientRect().height + 'px';
marker.hidden = idx === from;
});
th.addEventListener('drop', clear);
table.addEventListener('pointerdown', e => {
if (e.button !== 0 || cardsQuery.matches || colDrag.active) return;
const th = e.target.closest('thead th.custom-th');
if (!th || e.target.closest('.th-icons') || e.target.closest('.col-resize-handle')) return;
const headers = Array.from(th.parentElement.cells);
const from = headers.indexOf(th);
if (from < 0) return;
const st = { th, from, pos: from, x0: e.clientX, y0: e.clientY, started: false, cells: null, float: null };
colDrag.active = st;
const move = ev => onDragMove(ev, st, table);
const up = ev => { finish(ev, st, table, false); };
const key = ev => { if (ev.key === 'Escape' && st.started) finish(ev, st, table, true); };
st.cleanup = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('pointercancel', up);
window.removeEventListener('keydown', key, true);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
window.addEventListener('pointercancel', up);
window.addEventListener('keydown', key, true);
});
}
function onDragMove(e, st, table) {
if (!st.started) {
if (Math.hypot(e.clientX - st.x0, e.clientY - st.y0) < 6) return;
st.started = true;
st.cells = Array.from(table.rows).map(r => r.cells[st.from]).filter(Boolean);
st.cells.forEach(c => c.classList.add('col-ghost'));
st.float = document.createElement('div');
st.float.className = 'col-drag-float';
const label = st.th.querySelector('.label-text');
st.float.textContent = label ? label.textContent : st.th.textContent.trim();
document.body.appendChild(st.float);
document.body.classList.add('col-dragging');
const overlay = document.getElementById('custom-resize-overlay');
if (overlay) overlay.style.visibility = 'hidden';
}
e.preventDefault();
st.float.style.left = e.clientX + 'px';
st.float.style.top = e.clientY + 'px';
// Nudge the sideways scrollbar when dragging near the edges of a wide table
const bar = document.getElementById('custom-hscroll');
const box = document.getElementById('scroll-custom');
if (bar && !bar.hidden && box) {
const r = box.getBoundingClientRect();
if (e.clientX < r.left + 40) bar.scrollLeft -= 18;
else if (e.clientX > r.right - 40) bar.scrollLeft += 18;
}
// Target slot = number of other columns whose middle is left of the pointer
const headerCells = Array.from(st.th.parentElement.cells).filter(c => c !== st.th);
let pos = 0;
for (const c of headerCells) { const b = c.getBoundingClientRect(); if (e.clientX > b.left + b.width / 2) pos++; }
if (pos === st.pos) return;
st.pos = pos;
Array.from(table.rows).forEach((row, i) => {
const cell = st.cells[i];
if (!cell || cell.parentElement !== row) return;
const others = Array.from(row.cells).filter(c => c !== cell);
row.insertBefore(cell, others[pos] || null);
});
}
function finish(e, st, table, cancelled) {
st.cleanup();
colDrag.active = null;
if (!st.started) return; // a click: let it sort as usual
// swallow the click that follows a drag so it doesn't also sort
const stop = ev => { ev.stopPropagation(); ev.preventDefault(); };
window.addEventListener('click', stop, true);
setTimeout(() => window.removeEventListener('click', stop, true), 0);
st.cells.forEach(c => c.classList.remove('col-ghost'));
if (st.float) st.float.remove();
document.body.classList.remove('col-dragging');
const overlay = document.getElementById('custom-resize-overlay');
if (overlay) overlay.style.visibility = '';
if (!cancelled && st.pos !== st.from) moveCustomColumn(st.from, st.pos); // re-renders in the new order
else if (st.pos !== st.from || colDrag.pendingRender) renderCustomTable(); // put cells back
colDrag.pendingRender = false;
}
let queued = false;
function run() {
queued = false;
@@ -225,11 +266,14 @@
// Groups and pages are shown by toggling display (not a DOM change), so also
// re-measure as the user scrolls; writes only happen when a value changes.
window.addEventListener('scroll', schedule, { passive: true });
// Custom View rebuilds its header (and re-attaches drag handlers) on every render
if (typeof window.attachColDragHandlers === 'function') {
const origDrag = window.attachColDragHandlers;
window.attachColDragHandlers = function () { const r = origDrag.apply(this, arguments); enhanceColumnDrag(); return r; };
enhanceColumnDrag();
// Custom View column drag; a data refresh mid-drag waits until the drop
setupColumnDrag();
if (typeof window.renderCustomTable === 'function') {
const origRender = window.renderCustomTable;
window.renderCustomTable = function () {
if (colDrag.active && colDrag.active.started) { colDrag.pendingRender = true; return; }
return origRender.apply(this, arguments);
};
}
// Page switches show and hide pages without changing the DOM
if (typeof window.switchPage === 'function') {