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

+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') {