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:
1 parent
973ff592a4
commit
50c2a18a68
3 files changed
+274
-89
No files matched your search
+140
-17
@@ -7,7 +7,9 @@
|
|||||||
* The value list only shows values from rows that pass the OTHER columns' filters, filters
|
* 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.
|
* 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
|
* 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,
|
* It plugs into the dashboard's existing filter passes (applyFilters, filterSoftwareTab,
|
||||||
* filterCustomTab, refreshStackTable): those run first (global search etc.), then rows
|
* filterCustomTab, refreshStackTable): those run first (global search etc.), then rows
|
||||||
@@ -23,6 +25,8 @@
|
|||||||
const LIST_COLUMNS = /^(tags|missing software|specialty software)$/i;
|
const LIST_COLUMNS = /^(tags|missing software|specialty software)$/i;
|
||||||
const SKIP_COLUMNS = /(^|\s)apps$/i; // "Apps" button columns have nothing to filter
|
const SKIP_COLUMNS = /(^|\s)apps$/i; // "Apps" button columns have nothing to filter
|
||||||
const BLANK = '(Blanks)';
|
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 collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
|
||||||
const filters = {}; // table key -> { column key -> Set of allowed values }
|
const filters = {}; // table key -> { column key -> Set of allowed values }
|
||||||
@@ -111,6 +115,7 @@
|
|||||||
return {
|
return {
|
||||||
idx, th, label,
|
idx, th, label,
|
||||||
list: LIST_COLUMNS.test(label),
|
list: LIST_COLUMNS.test(label),
|
||||||
|
space: /space/i.test(label),
|
||||||
filterable: !!label && !SKIP_COLUMNS.test(label),
|
filterable: !!label && !SKIP_COLUMNS.test(label),
|
||||||
sortable: !!label && !SKIP_COLUMNS.test(label) && !!ad.sortable(th)
|
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 : [])
|
const dataRows = table => Array.from(table.tBodies[0] ? table.tBodies[0].rows : [])
|
||||||
.filter(r => !(r.cells.length === 1 && r.cells[0].colSpan > 1));
|
.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) {
|
function cellValues(td, col) {
|
||||||
let raw = td ? (td.getAttribute('data-val') != null ? td.getAttribute('data-val') : td.textContent) : '';
|
const raw = rawText(td);
|
||||||
raw = norm(raw);
|
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 (!raw) return [BLANK];
|
||||||
if (col.list) {
|
if (col.list) {
|
||||||
const parts = raw.split(',').map(norm).filter(Boolean);
|
const parts = raw.split(',').map(norm).filter(Boolean);
|
||||||
@@ -146,7 +159,15 @@
|
|||||||
if (k === exceptKey) continue;
|
if (k === exceptKey) continue;
|
||||||
const allowed = set[k];
|
const allowed = set[k];
|
||||||
if (!allowed) continue;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -160,7 +181,7 @@
|
|||||||
table.classList.add('xf-table');
|
table.classList.add('xf-table');
|
||||||
const cols = columnsOf(table, ad);
|
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;
|
let visible = 0;
|
||||||
for (const tr of dataRows(table)) {
|
for (const tr of dataRows(table)) {
|
||||||
@@ -186,7 +207,13 @@
|
|||||||
if (!col) return;
|
if (!col) return;
|
||||||
const body = table.tBodies[0];
|
const body = table.tBodies[0];
|
||||||
const rows = dataRows(table);
|
const rows = dataRows(table);
|
||||||
|
const num = tr => { const sp = parseSpace(tr.cells[s.col]); return sp ? sp[s.by] : null; };
|
||||||
rows.sort((a, b) => {
|
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 : ''));
|
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;
|
return s.dir === 'desc' ? -r : r;
|
||||||
});
|
});
|
||||||
@@ -215,7 +242,7 @@
|
|||||||
}
|
}
|
||||||
// Like Excel's button: funnel when filtered, an arrow when sorted, otherwise a caret
|
// Like Excel's button: funnel when filtered, an arrow when sorted, otherwise a caret
|
||||||
const on = activeKeys.has(ad.colKey(c.idx));
|
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';
|
const state = on ? 'filtered' : (dir === 'asc' || dir === 'desc') ? dir : 'plain';
|
||||||
btn.classList.toggle('xf-on', on);
|
btn.classList.toggle('xf-on', on);
|
||||||
btn.classList.toggle('xf-sorted', state === 'asc' || state === 'desc');
|
btn.classList.toggle('xf-sorted', state === 'asc' || state === 'desc');
|
||||||
@@ -261,7 +288,7 @@
|
|||||||
if (!c.filterable && !c.sortable) return;
|
if (!c.filterable && !c.sortable) return;
|
||||||
const on = activeKeys.has(ad.colKey(c.idx));
|
const on = activeKeys.has(ad.colKey(c.idx));
|
||||||
const n = on ? filters[ad.key][ad.colKey(c.idx)].size : 0;
|
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' ? ' ↑' : dir === 'desc' ? ' ↓' : '';
|
const arrow = dir === 'asc' ? ' ↑' : dir === 'desc' ? ' ↓' : '';
|
||||||
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>`);
|
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) {
|
function groupHeaderFor(table) {
|
||||||
for (let el = table.parentElement, depth = 0; el && depth < 3; el = el.parentElement, depth++) {
|
for (let el = table.parentElement, depth = 0; el && depth < 3; el = el.parentElement, depth++) {
|
||||||
const gh = el.querySelector(':scope > .group-header');
|
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); }
|
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
|
// "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('None')) values.unshift('None');
|
||||||
|
if (seen.has(NO_READING)) values.push(NO_READING);
|
||||||
if (hasBlank) values.push(BLANK);
|
if (hasBlank) values.push(BLANK);
|
||||||
|
|
||||||
menuCtx = { table, idx };
|
menuCtx = { table, idx };
|
||||||
@@ -357,13 +402,26 @@
|
|||||||
b.disabled = !!disabled; b.onclick = onClick;
|
b.disabled = !!disabled; b.onclick = onClick;
|
||||||
menu.appendChild(b);
|
menu.appendChild(b);
|
||||||
};
|
};
|
||||||
if (col.sortable) {
|
if (col.space) {
|
||||||
const dir = ad.sortDir(idx);
|
// Space columns sort by a number, not by text
|
||||||
item(SORT_ASC_SVG, 'Sort A to Z', () => { closeMenu(); ad.sort(idx, dir === 'asc' ? 'none' : 'asc'); });
|
const ns = nativeSort[ad.key];
|
||||||
item(SORT_DESC_SVG, 'Sort Z to A', () => { closeMenu(); ad.sort(idx, dir === 'desc' ? 'none' : 'desc'); });
|
const cur = ns && ns.col === idx && ns.dir !== 'none' ? `${ns.by}-${ns.dir}` : '';
|
||||||
const marks = menu.querySelectorAll('.xf-item');
|
const opt = (svg, text, by, dir) => {
|
||||||
if (dir === 'asc') marks[0].classList.add('xf-checked');
|
const id = `${by}-${dir}`;
|
||||||
if (dir === 'desc') marks[1].classList.add('xf-checked');
|
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'));
|
menu.appendChild(el('div', 'xf-sep'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +432,58 @@
|
|||||||
}, !current);
|
}, !current);
|
||||||
|
|
||||||
const search = el('input', 'xf-search'); search.type = 'search'; search.placeholder = 'Search';
|
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 list = el('div', 'xf-list');
|
||||||
const mkRow = (label, checked, cls) => {
|
const mkRow = (label, checked, cls) => {
|
||||||
@@ -455,8 +564,12 @@
|
|||||||
ok.onclick = () => {
|
ok.onclick = () => {
|
||||||
const q = search.value.trim();
|
const q = search.value.trim();
|
||||||
const chosen = new Set(visibleOpts().filter(o => o.cb.checked).map(o => o.v));
|
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] || {};
|
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;
|
else filters[ad.key][key] = chosen;
|
||||||
closeMenu(); ad.rerun();
|
closeMenu(); ad.rerun();
|
||||||
};
|
};
|
||||||
@@ -602,6 +715,16 @@
|
|||||||
customFilters = {};
|
customFilters = {};
|
||||||
if (typeof saveCustomState === 'function') saveCustomState();
|
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
|
// Custom View's own filter icon opens the Excel-style menu instead
|
||||||
window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor);
|
window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor);
|
||||||
|
|
||||||
|
|||||||
+33
-15
@@ -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); }
|
.xf-opt.xf-none { font-style: italic; border-bottom: 1px dashed var(--border-light); }
|
||||||
|
|
||||||
/* Custom View column drag preview (layout.js) */
|
/* Custom View column drag (layout.js): the column moves live as a ghost, a label follows */
|
||||||
.col-drag-ghost {
|
#table-custom thead th.custom-th .col-label-row { cursor: grab; }
|
||||||
position: fixed; top: -1000px; left: -1000px; z-index: 9999; pointer-events: none;
|
body.col-dragging, body.col-dragging * { cursor: grabbing !important; user-select: none !important; }
|
||||||
background: var(--bg-card); color: var(--text-main); border: 2px solid #3498db; border-radius: 6px;
|
#table-custom .col-ghost {
|
||||||
box-shadow: 0 10px 24px rgba(0,0,0,0.45); overflow: hidden; font-size: 12px;
|
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); }
|
#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-ghost-cell {
|
.col-drag-float {
|
||||||
padding: 5px 10px; border-top: 1px solid var(--border-light);
|
position: fixed; z-index: 9999; pointer-events: none; white-space: nowrap;
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
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 {
|
/* Filter menu: sections, free-space rule, any/only toggle */
|
||||||
position: absolute; top: 0; width: 4px; z-index: 30; pointer-events: none;
|
.xf-section { padding: 2px 12px 6px; }
|
||||||
background: #3498db; border-radius: 2px; box-shadow: 0 0 8px rgba(52,152,219,0.8);
|
.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 */
|
.xf-seg { display: flex; margin: 0 12px 6px; border: 1px solid var(--border-color); border-radius: 6px; overflow: hidden; }
|
||||||
#table-custom .custom-th.drag-over { box-shadow: none !important; }
|
.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; }
|
||||||
+101
-57
@@ -142,67 +142,108 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom View column drag: a ghost of the column follows the cursor, the column being
|
// Custom View column drag (desktop). Grab a column by its grip or anywhere on its header
|
||||||
// moved is dimmed, and a line shows exactly where it will land. Adds to the dashboard's
|
// (a plain click still sorts). While dragging, the real column slides into the place it
|
||||||
// own drag-and-drop (attachColDragHandlers), which still performs the move.
|
// would land, drawn as a translucent ghost, and a label follows the pointer. Only the
|
||||||
function enhanceColumnDrag() {
|
// 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 table = document.getElementById('table-custom');
|
||||||
const host = document.getElementById('custom-table-host');
|
if (!table || table.dataset.pdrag) return;
|
||||||
if (!table || !host) return;
|
table.dataset.pdrag = '1';
|
||||||
const headers = Array.from(table.querySelectorAll('thead th.custom-th'));
|
|
||||||
let from = null, marker = null;
|
|
||||||
|
|
||||||
const setDimmed = (idx, on) => {
|
// Native drag-and-drop on the grips is replaced by the pointer drag below
|
||||||
for (const row of table.rows) { const c = row.cells[idx]; if (c) c.classList.toggle('col-moving', on); }
|
table.addEventListener('dragstart', e => { if (e.target.closest && e.target.closest('thead')) e.preventDefault(); }, true);
|
||||||
};
|
|
||||||
const clear = () => {
|
table.addEventListener('pointerdown', e => {
|
||||||
if (from !== null) setDimmed(from, false);
|
if (e.button !== 0 || cardsQuery.matches || colDrag.active) return;
|
||||||
if (marker) marker.remove();
|
const th = e.target.closest('thead th.custom-th');
|
||||||
marker = null; from = null;
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
headers.forEach((th, idx) => {
|
function onDragMove(e, st, table) {
|
||||||
const grip = th.querySelector('.col-grip');
|
if (!st.started) {
|
||||||
if (!grip || grip.dataset.ghost) return;
|
if (Math.hypot(e.clientX - st.x0, e.clientY - st.y0) < 6) return;
|
||||||
grip.dataset.ghost = '1';
|
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';
|
||||||
|
|
||||||
grip.addEventListener('dragstart', e => {
|
// Nudge the sideways scrollbar when dragging near the edges of a wide table
|
||||||
from = idx;
|
const bar = document.getElementById('custom-hscroll');
|
||||||
const ghost = document.createElement('div');
|
const box = document.getElementById('scroll-custom');
|
||||||
ghost.className = 'col-drag-ghost';
|
if (bar && !bar.hidden && box) {
|
||||||
const head = document.createElement('div');
|
const r = box.getBoundingClientRect();
|
||||||
head.className = 'col-drag-ghost-head';
|
if (e.clientX < r.left + 40) bar.scrollLeft -= 18;
|
||||||
head.textContent = th.querySelector('.label-text') ? th.querySelector('.label-text').textContent : th.textContent.trim();
|
else if (e.clientX > r.right - 40) bar.scrollLeft += 18;
|
||||||
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', () => {
|
// Target slot = number of other columns whose middle is left of the pointer
|
||||||
if (from === null) return;
|
const headerCells = Array.from(st.th.parentElement.cells).filter(c => c !== st.th);
|
||||||
if (!marker) { marker = document.createElement('div'); marker.className = 'col-drop-marker'; host.appendChild(marker); }
|
let pos = 0;
|
||||||
// moveCustomColumn(from, to) lands the column after the target when moving right,
|
for (const c of headerCells) { const b = c.getBoundingClientRect(); if (e.clientX > b.left + b.width / 2) pos++; }
|
||||||
// before it when moving left
|
if (pos === st.pos) return;
|
||||||
const hostBox = host.getBoundingClientRect(), box = th.getBoundingClientRect();
|
st.pos = pos;
|
||||||
const x = (idx > from ? box.right : box.left) - hostBox.left;
|
Array.from(table.rows).forEach((row, i) => {
|
||||||
marker.style.left = (x - 2) + 'px';
|
const cell = st.cells[i];
|
||||||
marker.style.height = table.getBoundingClientRect().height + 'px';
|
if (!cell || cell.parentElement !== row) return;
|
||||||
marker.hidden = idx === from;
|
const others = Array.from(row.cells).filter(c => c !== cell);
|
||||||
});
|
row.insertBefore(cell, others[pos] || null);
|
||||||
th.addEventListener('drop', clear);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
let queued = false;
|
||||||
function run() {
|
function run() {
|
||||||
queued = false;
|
queued = false;
|
||||||
@@ -225,11 +266,14 @@
|
|||||||
// Groups and pages are shown by toggling display (not a DOM change), so also
|
// 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.
|
// re-measure as the user scrolls; writes only happen when a value changes.
|
||||||
window.addEventListener('scroll', schedule, { passive: true });
|
window.addEventListener('scroll', schedule, { passive: true });
|
||||||
// Custom View rebuilds its header (and re-attaches drag handlers) on every render
|
// Custom View column drag; a data refresh mid-drag waits until the drop
|
||||||
if (typeof window.attachColDragHandlers === 'function') {
|
setupColumnDrag();
|
||||||
const origDrag = window.attachColDragHandlers;
|
if (typeof window.renderCustomTable === 'function') {
|
||||||
window.attachColDragHandlers = function () { const r = origDrag.apply(this, arguments); enhanceColumnDrag(); return r; };
|
const origRender = window.renderCustomTable;
|
||||||
enhanceColumnDrag();
|
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
|
// Page switches show and hide pages without changing the DOM
|
||||||
if (typeof window.switchPage === 'function') {
|
if (typeof window.switchPage === 'function') {
|
||||||
|
|||||||
Reference in new issue
Block a user