diff --git a/web/excel-filter.js b/web/excel-filter.js index 757f763..4ab3b02 100644 --- a/web/excel-filter.js +++ b/web/excel-filter.js @@ -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' ? ' ↑' : dir === 'desc' ? ' ↓' : ''; parts.push(``); }); @@ -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); diff --git a/web/layout.css b/web/layout.css index eeb8f4e..ee852ba 100644 --- a/web/layout.css +++ b/web/layout.css @@ -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; } diff --git a/web/layout.js b/web/layout.js index 945e812..fdd0dca 100644 --- a/web/layout.js +++ b/web/layout.js @@ -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') {