table optimization (filtering, resizing and settings saved in user settings
This commit is contained in:
@@ -3122,3 +3122,28 @@ html[data-theme="dark"] .column-filter-row textarea.filter-input-active{
|
||||
color:#fff2b3;
|
||||
border-color:#d4a735;
|
||||
}
|
||||
|
||||
/* Advanced numeric/date filter helper. The free-form filter remains the source of truth. */
|
||||
.column-filter-row th.advanced-filter-cell{position:relative;overflow:visible}
|
||||
.advanced-filter-popup{position:absolute;z-index:50;top:calc(100% + 4px);left:4px;min-width:250px;padding:10px;border:1px solid var(--border-color,#c7d0d8);border-radius:6px;background:var(--surface-color,#fff);box-shadow:0 8px 24px rgba(0,0,0,.18);text-align:left}
|
||||
.advanced-filter-popup[hidden]{display:none}
|
||||
.advanced-filter-title{display:block;margin-bottom:8px}
|
||||
.advanced-filter-popup label{display:block;margin:6px 0;font-size:12px;font-weight:600}
|
||||
.advanced-filter-popup select,.advanced-filter-popup input{display:block;width:100%;min-width:0;margin-top:4px;padding:6px;font-size:13px}
|
||||
.advanced-filter-values{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.advanced-filter-values label:only-child{grid-column:1/-1}
|
||||
.advanced-filter-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:10px}
|
||||
.advanced-filter-actions .button{padding:6px 10px;font-size:12px}
|
||||
html[data-theme="dark"] .advanced-filter-popup{background:#202a34;color:#e8edf2;border-color:#53606d;box-shadow:0 8px 24px rgba(0,0,0,.45)}
|
||||
html[data-theme="dark"] .advanced-filter-popup select,html[data-theme="dark"] .advanced-filter-popup input{background:#172029;color:#e8edf2;border-color:#596673}
|
||||
|
||||
/* v0.5.5.71: advanced filter popup lives below body so table scrollers cannot clip it. */
|
||||
.advanced-filter-popup-portal{
|
||||
position:fixed;
|
||||
z-index:10000;
|
||||
top:0;
|
||||
left:0;
|
||||
}
|
||||
|
||||
/* v0.5.5.72: hidden value fields in advanced filter builder must stay hidden despite label display rules. */
|
||||
.advanced-filter-popup label[hidden]{display:none!important}
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
}
|
||||
|
||||
window.addEventListener('resize', scheduleScrollbarUpdate, { passive: true });
|
||||
window.addEventListener('asset-table-columns-changed', scheduleScrollbarUpdate, { passive: true });
|
||||
|
||||
if ('ResizeObserver' in window && scroll) {
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
|
||||
+156
-25
@@ -1,24 +1,165 @@
|
||||
(() => {
|
||||
const perf = window.AssetPerf;
|
||||
perf?.start('browser-state:gesamtes Modul');
|
||||
|
||||
const persistent = document.body?.dataset.persistBrowserState === 'true';
|
||||
const storage = persistent ? window.localStorage : window.sessionStorage;
|
||||
const fallbackStorage = persistent ? window.localStorage : window.sessionStorage;
|
||||
const alternateStorage = persistent ? window.sessionStorage : window.localStorage;
|
||||
const prefix = 'assetmanager:';
|
||||
|
||||
let serverBacked = false;
|
||||
let serverState = {};
|
||||
let pendingUpdates = {};
|
||||
let pendingRemovals = new Set();
|
||||
let saveTimer = null;
|
||||
let saveChain = Promise.resolve();
|
||||
|
||||
function browserGet(storage, key) {
|
||||
try { return storage.getItem(prefix + key); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function browserSet(storage, key, value) {
|
||||
try { storage.setItem(prefix + key, value); } catch (_) {}
|
||||
}
|
||||
|
||||
function browserRemove(storage, key) {
|
||||
try { storage.removeItem(prefix + key); } catch (_) {}
|
||||
}
|
||||
|
||||
function collectBrowserState() {
|
||||
const result = {};
|
||||
const collect = storage => {
|
||||
try {
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const fullKey = storage.key(index);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const key = fullKey.slice(prefix.length);
|
||||
if (!key || Object.prototype.hasOwnProperty.call(result, key)) continue;
|
||||
const value = storage.getItem(fullKey);
|
||||
if (value !== null) result[key] = value;
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
// Prefer the storage mode that was active for this user before the update.
|
||||
collect(fallbackStorage);
|
||||
collect(alternateStorage);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function postState(updates = {}, removals = []) {
|
||||
const response = await fetch('/api/user-ui-state', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({updates, remove: removals}),
|
||||
keepalive: true,
|
||||
});
|
||||
if (!response.ok) throw new Error(`UI state save failed (${response.status})`);
|
||||
}
|
||||
|
||||
function flushPending() {
|
||||
if (!serverBacked) return Promise.resolve();
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
const updates = pendingUpdates;
|
||||
const removals = [...pendingRemovals];
|
||||
pendingUpdates = {};
|
||||
pendingRemovals = new Set();
|
||||
if (!Object.keys(updates).length && !removals.length) return saveChain;
|
||||
|
||||
saveChain = saveChain
|
||||
.catch(() => {})
|
||||
.then(() => postState(updates, removals))
|
||||
.catch(error => console.warn('[AssetManager] Benutzerbezogener Tabellenzustand konnte nicht gespeichert werden.', error));
|
||||
return saveChain;
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (!serverBacked) return;
|
||||
if (saveTimer !== null) clearTimeout(saveTimer);
|
||||
saveTimer = window.setTimeout(flushPending, 350);
|
||||
}
|
||||
|
||||
const api = {
|
||||
persistent,
|
||||
serverBacked: false,
|
||||
ready: Promise.resolve(),
|
||||
get(key) {
|
||||
try { return storage.getItem(prefix + key); } catch (_) { return null; }
|
||||
const normalized = String(key || '');
|
||||
if (!normalized) return null;
|
||||
if (serverBacked) {
|
||||
const value = serverState[normalized];
|
||||
return value === undefined || value === null ? null : String(value);
|
||||
}
|
||||
return browserGet(fallbackStorage, normalized);
|
||||
},
|
||||
set(key, value) {
|
||||
try { storage.setItem(prefix + key, value); } catch (_) {}
|
||||
const normalized = String(key || '');
|
||||
if (!normalized) return;
|
||||
const serialized = String(value);
|
||||
if (serverBacked) {
|
||||
serverState[normalized] = serialized;
|
||||
pendingUpdates[normalized] = serialized;
|
||||
pendingRemovals.delete(normalized);
|
||||
scheduleSave();
|
||||
return;
|
||||
}
|
||||
browserSet(fallbackStorage, normalized, serialized);
|
||||
},
|
||||
remove(key) {
|
||||
try { storage.removeItem(prefix + key); } catch (_) {}
|
||||
}
|
||||
const normalized = String(key || '');
|
||||
if (!normalized) return;
|
||||
if (serverBacked) {
|
||||
delete serverState[normalized];
|
||||
delete pendingUpdates[normalized];
|
||||
pendingRemovals.add(normalized);
|
||||
scheduleSave();
|
||||
return;
|
||||
}
|
||||
browserRemove(fallbackStorage, normalized);
|
||||
},
|
||||
flush: flushPending,
|
||||
};
|
||||
window.AssetBrowserState = api;
|
||||
|
||||
async function loadUserState() {
|
||||
try {
|
||||
const response = await fetch('/api/user-ui-state', {credentials: 'same-origin', cache: 'no-store'});
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
if (!payload?.authenticated) return;
|
||||
|
||||
serverBacked = true;
|
||||
api.serverBacked = true;
|
||||
serverState = payload.state && typeof payload.state === 'object' && !Array.isArray(payload.state)
|
||||
? {...payload.state}
|
||||
: {};
|
||||
|
||||
// One-time/non-destructive migration: existing values from this browser are
|
||||
// copied only when the account does not already have a value for the key.
|
||||
const browserState = collectBrowserState();
|
||||
const migration = {};
|
||||
Object.entries(browserState).forEach(([key, value]) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(serverState, key)) {
|
||||
serverState[key] = value;
|
||||
migration[key] = value;
|
||||
}
|
||||
});
|
||||
if (Object.keys(migration).length) {
|
||||
try { await postState(migration, []); }
|
||||
catch (error) { console.warn('[AssetManager] Vorhandener Browserzustand konnte nicht in das Benutzerkonto übernommen werden.', error); }
|
||||
}
|
||||
} catch (error) {
|
||||
// Browser storage remains the safe fallback if the account-state endpoint
|
||||
// is temporarily unavailable.
|
||||
console.warn('[AssetManager] Benutzerbezogener Tabellenzustand nicht verfügbar; Browser-Fallback wird verwendet.', error);
|
||||
}
|
||||
}
|
||||
|
||||
api.ready = loadUserState();
|
||||
|
||||
function tableKey(table, index) {
|
||||
return table.dataset.storageKey || table.id || `table-${location.pathname}-${index}`;
|
||||
}
|
||||
@@ -58,11 +199,6 @@
|
||||
|
||||
function restoreTable(table, index) {
|
||||
if (table.dataset.browserStateReady === '1') return;
|
||||
if (table.dataset.serverFilters === '1') {
|
||||
table.dataset.browserStateReady = '1';
|
||||
restoreSort(table, index);
|
||||
return;
|
||||
}
|
||||
const restoreStarted = performance.now();
|
||||
const controls = filterControls(table);
|
||||
if (!controls.length) {
|
||||
@@ -76,10 +212,11 @@
|
||||
let values = {};
|
||||
try { values = JSON.parse(api.get(key) || '{}') || {}; } catch (_) {}
|
||||
|
||||
let serverValuesChanged = false;
|
||||
controls.forEach((control, controlIndex) => {
|
||||
const field = controlKey(control, controlIndex);
|
||||
if (Object.prototype.hasOwnProperty.call(values, field)) {
|
||||
// Restore without dispatching one input/change pair per column.
|
||||
if (control.value !== values[field]) serverValuesChanged = true;
|
||||
control.value = values[field];
|
||||
}
|
||||
const save = () => {
|
||||
@@ -93,9 +230,9 @@
|
||||
control.addEventListener('change', save);
|
||||
});
|
||||
|
||||
// One filtering pass after every stored value is in place.
|
||||
if (typeof table.assetApplyFilters === 'function') {
|
||||
table.assetApplyFilters();
|
||||
if (typeof table.assetApplyFilters === 'function') table.assetApplyFilters();
|
||||
if (table.dataset.serverFilters === '1' && serverValuesChanged && typeof table.assetSubmitServerFilters === 'function') {
|
||||
table.assetSubmitServerFilters(true);
|
||||
}
|
||||
restoreSort(table, index);
|
||||
table.dataset.browserStateRestored = '1';
|
||||
@@ -109,7 +246,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initializeTables() {
|
||||
async function initializeTables() {
|
||||
await api.ready;
|
||||
document.querySelectorAll('table').forEach(restoreTable);
|
||||
}
|
||||
|
||||
@@ -119,21 +257,14 @@
|
||||
initializeTables();
|
||||
}
|
||||
|
||||
// Filter controls are added by table-tools.js. Initialize only on its explicit
|
||||
// completion event instead of observing every DOM mutation in the document.
|
||||
window.addEventListener('asset-table-structure-ready', initializeTables);
|
||||
|
||||
// A programmatic reset does not emit input/change events. Remove the
|
||||
// corresponding persisted filter state explicitly so it cannot return after
|
||||
// a page refresh.
|
||||
window.addEventListener('asset-table-filters-cleared', event => {
|
||||
const table = event.detail?.table;
|
||||
if (!(table instanceof HTMLTableElement)) return;
|
||||
|
||||
const tables = [...document.querySelectorAll('table')];
|
||||
const index = tables.indexOf(table);
|
||||
const key = `${tableKey(table, index >= 0 ? index : 0)}:filters`;
|
||||
api.remove(key);
|
||||
api.remove(`${tableKey(table, index >= 0 ? index : 0)}:filters`);
|
||||
});
|
||||
|
||||
window.addEventListener('asset-table-sort-changed', event => {
|
||||
@@ -141,8 +272,7 @@
|
||||
if (!(table instanceof HTMLTableElement)) return;
|
||||
const tables = [...document.querySelectorAll('table')];
|
||||
const index = tables.indexOf(table);
|
||||
const key = `${tableKey(table, index >= 0 ? index : 0)}:sort`;
|
||||
api.set(key, JSON.stringify({
|
||||
api.set(`${tableKey(table, index >= 0 ? index : 0)}:sort`, JSON.stringify({
|
||||
field: event.detail?.field || '',
|
||||
direction: event.detail?.direction || 'asc'
|
||||
}));
|
||||
@@ -156,5 +286,6 @@
|
||||
restoreSort(table, index >= 0 ? index : 0, true);
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => { flushPending(); });
|
||||
perf?.end('browser-state:gesamtes Modul');
|
||||
})();
|
||||
|
||||
+446
-139
@@ -1,4 +1,14 @@
|
||||
(() => {
|
||||
const uiState = window.AssetBrowserState;
|
||||
const stateGet = key => {
|
||||
if (uiState) return uiState.get(key);
|
||||
try { return window.localStorage.getItem(`assetmanager:${key}`); } catch (_) { return null; }
|
||||
};
|
||||
const stateSet = (key, value) => {
|
||||
if (uiState) { uiState.set(key, value); return; }
|
||||
try { window.localStorage.setItem(`assetmanager:${key}`, String(value)); } catch (_) {}
|
||||
};
|
||||
|
||||
function normalizeSearchText(value, options = {}) {
|
||||
const caseSensitive = Boolean(options.caseSensitive);
|
||||
const collapseWhitespace = Boolean(options.collapseWhitespace);
|
||||
@@ -179,14 +189,14 @@
|
||||
|
||||
const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]');
|
||||
const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname;
|
||||
const rowNumberWidthKey = `assetmanager:table:${rowNumberStoragePart}:row-number-width`;
|
||||
const rowNumberWidthKey = `table:${rowNumberStoragePart}:row-number-width`;
|
||||
const defaultRowNumberWidth = 64;
|
||||
const minimumRowNumberWidth = 48;
|
||||
const maximumRowNumberWidth = 180;
|
||||
|
||||
function readStoredRowNumberWidth() {
|
||||
try {
|
||||
const stored = Number.parseInt(window.localStorage.getItem(rowNumberWidthKey) || '', 10);
|
||||
const stored = Number.parseInt(stateGet(rowNumberWidthKey) || '', 10);
|
||||
if (Number.isFinite(stored)) {
|
||||
return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored));
|
||||
}
|
||||
@@ -213,7 +223,7 @@
|
||||
|
||||
const saveWidth = width => {
|
||||
const normalized = applyRowNumberWidth(width);
|
||||
try { window.localStorage.setItem(rowNumberWidthKey, String(normalized)); } catch (_) {}
|
||||
try { stateSet(rowNumberWidthKey, String(normalized)); } catch (_) {}
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', event => {
|
||||
@@ -254,9 +264,9 @@
|
||||
// stored per table and per field/index so they survive navigation,
|
||||
// reloads and container updates. The visual row-number column keeps its
|
||||
// dedicated implementation above.
|
||||
const columnWidthStoragePrefix = `assetmanager:table:${rowNumberStoragePart}:column-width:`;
|
||||
const columnWidthStoragePrefix = `table:${rowNumberStoragePart}:column-width:`;
|
||||
const minimumColumnWidth = 56;
|
||||
const maximumColumnWidth = 1200;
|
||||
// No arbitrary upper limit: widening a column grows the whole table.
|
||||
|
||||
function columnStorageId(header, columnIndex) {
|
||||
const field = header?.dataset?.field;
|
||||
@@ -265,7 +275,7 @@
|
||||
}
|
||||
|
||||
function normalizeColumnWidth(width) {
|
||||
return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width)));
|
||||
return Math.max(minimumColumnWidth, Math.round(width));
|
||||
}
|
||||
|
||||
function applyColumnWidth(columnIndex, width) {
|
||||
@@ -287,7 +297,7 @@
|
||||
function readStoredColumnWidth(header, columnIndex) {
|
||||
try {
|
||||
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
|
||||
const stored = Number.parseInt(window.localStorage.getItem(key) || '', 10);
|
||||
const stored = Number.parseInt(stateGet(key) || '', 10);
|
||||
if (Number.isFinite(stored)) return normalizeColumnWidth(stored);
|
||||
} catch (_) {}
|
||||
return null;
|
||||
@@ -295,19 +305,22 @@
|
||||
|
||||
function saveColumnWidth(header, columnIndex, width) {
|
||||
const normalized = applyColumnWidth(columnIndex, width);
|
||||
if (header?.dataset?.field) header.dataset.columnWidth = String(normalized);
|
||||
try {
|
||||
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
|
||||
window.localStorage.setItem(key, String(normalized));
|
||||
stateSet(key, String(normalized));
|
||||
} catch (_) {}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function freezeTableGeometry() {
|
||||
if (table.id === 'asset-table') return;
|
||||
const widths = [...headerRow.cells].map(cell => Math.max(minimumColumnWidth, Math.round(cell.getBoundingClientRect().width || minimumColumnWidth)));
|
||||
const widths = [...headerRow.cells].map(cell => {
|
||||
const measured = Math.round(cell.getBoundingClientRect().width || 0);
|
||||
return measured > 0 ? Math.max(minimumColumnWidth, measured) : 0;
|
||||
});
|
||||
const total = widths.reduce((sum, width) => sum + width, 0);
|
||||
widths.forEach((width, columnIndex) => {
|
||||
if (headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return;
|
||||
if (!width || headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return;
|
||||
applyColumnWidth(columnIndex, width);
|
||||
});
|
||||
table.style.tableLayout = 'fixed';
|
||||
@@ -315,15 +328,25 @@
|
||||
table.style.minWidth = `${total}px`;
|
||||
}
|
||||
|
||||
function synchronizeTableWidth() {
|
||||
if (table.id === 'asset-table') return;
|
||||
const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0);
|
||||
if (total > 0) {
|
||||
table.style.width = `${total}px`;
|
||||
table.style.minWidth = `${total}px`;
|
||||
function setTableWidth(total) {
|
||||
const normalized = Math.max(1, Math.round(total || 0));
|
||||
table.style.width = `${normalized}px`;
|
||||
table.style.minWidth = `${normalized}px`;
|
||||
if (table.id === 'asset-table') {
|
||||
table.dataset.calculatedWidth = String(normalized);
|
||||
window.dispatchEvent(new Event('asset-table-columns-changed'));
|
||||
}
|
||||
}
|
||||
|
||||
function currentTableWidth() {
|
||||
return [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0);
|
||||
}
|
||||
|
||||
function synchronizeTableWidth() {
|
||||
const total = currentTableWidth();
|
||||
if (total > 0) setTableWidth(total);
|
||||
}
|
||||
|
||||
function applyStoredColumnWidths() {
|
||||
let restored = false;
|
||||
[...headerRow.cells].forEach((header, columnIndex) => {
|
||||
@@ -366,39 +389,25 @@
|
||||
const startX = event.clientX;
|
||||
freezeTableGeometry();
|
||||
|
||||
const headers = [...headerRow.cells];
|
||||
const rightmostIndex = headers.length - 1;
|
||||
// A header can be moved after the resizer was installed. Always use
|
||||
// its current DOM position instead of the index captured at startup.
|
||||
const currentColumnIndex = header.cellIndex;
|
||||
const startWidth = header.getBoundingClientRect().width || minimumColumnWidth;
|
||||
const rightmostHeader = headers[rightmostIndex];
|
||||
const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth;
|
||||
const keepTableWidth = columnIndex !== rightmostIndex;
|
||||
const startTableWidth = currentTableWidth();
|
||||
|
||||
document.documentElement.classList.add('table-column-resizing');
|
||||
|
||||
const resizePair = delta => {
|
||||
if (!keepTableWidth) {
|
||||
applyColumnWidth(columnIndex, startWidth + delta);
|
||||
synchronizeTableWidth();
|
||||
return;
|
||||
}
|
||||
|
||||
// All columns between the dragged column and the far-right column
|
||||
// stay unchanged. The far-right column alone absorbs the delta.
|
||||
const minDelta = minimumColumnWidth - startWidth;
|
||||
const maxDelta = rightmostStartWidth - minimumColumnWidth;
|
||||
const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta));
|
||||
applyColumnWidth(columnIndex, startWidth + effectiveDelta);
|
||||
applyColumnWidth(rightmostIndex, rightmostStartWidth - effectiveDelta);
|
||||
// Resize only the dragged column. The table width follows the actual
|
||||
// column delta exactly; all other columns keep their current widths.
|
||||
const resizeColumn = delta => {
|
||||
const newWidth = applyColumnWidth(currentColumnIndex, startWidth + delta);
|
||||
setTableWidth(startTableWidth + (newWidth - startWidth));
|
||||
};
|
||||
|
||||
const move = moveEvent => resizePair(moveEvent.clientX - startX);
|
||||
const move = moveEvent => resizeColumn(moveEvent.clientX - startX);
|
||||
const finish = () => {
|
||||
saveColumnWidth(header, columnIndex, header.getBoundingClientRect().width || startWidth);
|
||||
if (keepTableWidth && rightmostHeader) {
|
||||
saveColumnWidth(rightmostHeader, rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth);
|
||||
} else {
|
||||
synchronizeTableWidth();
|
||||
}
|
||||
saveColumnWidth(header, currentColumnIndex, header.getBoundingClientRect().width || startWidth);
|
||||
synchronizeTableWidth();
|
||||
document.documentElement.classList.remove('table-column-resizing');
|
||||
window.removeEventListener('pointermove', move, true);
|
||||
window.removeEventListener('pointerup', finish, true);
|
||||
@@ -414,22 +423,11 @@
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
freezeTableGeometry();
|
||||
const headers = [...headerRow.cells];
|
||||
const rightmostIndex = headers.length - 1;
|
||||
const currentColumnIndex = header.cellIndex;
|
||||
const step = event.key === 'ArrowRight' ? 12 : -12;
|
||||
const current = header.getBoundingClientRect().width || minimumColumnWidth;
|
||||
if (columnIndex === rightmostIndex) {
|
||||
saveColumnWidth(header, columnIndex, current + step);
|
||||
synchronizeTableWidth();
|
||||
return;
|
||||
}
|
||||
const rightmostHeader = headers[rightmostIndex];
|
||||
const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth;
|
||||
const minDelta = minimumColumnWidth - current;
|
||||
const maxDelta = rightmostWidth - minimumColumnWidth;
|
||||
const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step));
|
||||
saveColumnWidth(header, columnIndex, current + effectiveDelta);
|
||||
if (rightmostHeader) saveColumnWidth(rightmostHeader, rightmostIndex, rightmostWidth - effectiveDelta);
|
||||
saveColumnWidth(header, currentColumnIndex, current + step);
|
||||
synchronizeTableWidth();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -592,17 +590,89 @@
|
||||
});
|
||||
}
|
||||
|
||||
function numericFilterMatches(rawValue, rawTerm) {
|
||||
const value = Number(String(rawValue).trim().replace(',', '.'));
|
||||
if (!Number.isFinite(value)) return false;
|
||||
function comparableCellValue(cell) {
|
||||
if (!cell) return '';
|
||||
if (cell.dataset.sortValue !== undefined && String(cell.dataset.sortValue).trim()) {
|
||||
return String(cell.dataset.sortValue).trim();
|
||||
}
|
||||
if (cell.dataset.filterValue !== undefined && String(cell.dataset.filterValue).trim()) {
|
||||
return String(cell.dataset.filterValue).trim();
|
||||
}
|
||||
return textValue(cell).trim();
|
||||
}
|
||||
|
||||
const term = String(rawTerm || '').trim().toLocaleLowerCase();
|
||||
function parseFilterNumber(rawValue) {
|
||||
const text = String(rawValue ?? '').trim().replace(/\s/g, '').replace(',', '.');
|
||||
if (!/^[-+]?\d+(?:\.\d+)?$/.test(text)) return null;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseFilterDate(rawValue) {
|
||||
const text = String(rawValue ?? '').trim();
|
||||
if (!text) return null;
|
||||
|
||||
let match = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/);
|
||||
if (match) {
|
||||
if (match[4] && (text.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(text))) {
|
||||
const timestamp = Date.parse(text.replace(' ', 'T'));
|
||||
if (!Number.isNaN(timestamp)) return {timestamp, dateOnly: false};
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const hour = Number(match[4] || 0);
|
||||
const minute = Number(match[5] || 0);
|
||||
const second = Number(match[6] || 0);
|
||||
const value = new Date(year, month - 1, day, hour, minute, second);
|
||||
if (
|
||||
value.getFullYear() === year && value.getMonth() === month - 1 &&
|
||||
value.getDate() === day && value.getHours() === hour && value.getMinutes() === minute
|
||||
) {
|
||||
return {timestamp: value.getTime(), dateOnly: !match[4]};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
match = text.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
||||
if (match) {
|
||||
let year = Number(match[3]);
|
||||
if (year < 100) year += year >= 70 ? 1900 : 2000;
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[1]);
|
||||
const hour = Number(match[4] || 0);
|
||||
const minute = Number(match[5] || 0);
|
||||
const second = Number(match[6] || 0);
|
||||
const value = new Date(year, month - 1, day, hour, minute, second);
|
||||
if (
|
||||
value.getFullYear() === year && value.getMonth() === month - 1 &&
|
||||
value.getDate() === day && value.getHours() === hour && value.getMinutes() === minute
|
||||
) {
|
||||
return {timestamp: value.getTime(), dateOnly: !match[4]};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rangeParts(term) {
|
||||
let match = term.match(/^(?:von|from)\s+(.+?)\s+(?:bis|to)\s+(.+)$/i);
|
||||
if (!match) match = term.match(/^(?:zwischen|between)\s+(.+?)\s+(?:und|and)\s+(.+)$/i);
|
||||
if (!match) match = term.match(/^(.+?)\s*\.\.\s*(.+)$/);
|
||||
if (!match) match = term.match(/^(.+?)\s+-\s+(.+)$/);
|
||||
return match ? [match[1].trim(), match[2].trim()] : null;
|
||||
}
|
||||
|
||||
function numericFilterMatches(rawValue, rawTerm, allowPlainExact = true) {
|
||||
const value = parseFilterNumber(rawValue);
|
||||
if (value === null) return null;
|
||||
|
||||
const term = String(rawTerm || '').trim();
|
||||
if (!term) return true;
|
||||
|
||||
const normalize = input => input.replace(',', '.').trim();
|
||||
let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/);
|
||||
if (match) {
|
||||
const expected = Number(normalize(match[2]));
|
||||
const expected = parseFilterNumber(match[2]);
|
||||
if (expected === null) return null;
|
||||
if (match[1] === '<') return value < expected;
|
||||
if (match[1] === '<=') return value <= expected;
|
||||
if (match[1] === '>') return value > expected;
|
||||
@@ -610,22 +680,279 @@
|
||||
return value === expected;
|
||||
}
|
||||
|
||||
match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/);
|
||||
if (!match) {
|
||||
match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/);
|
||||
}
|
||||
if (match) {
|
||||
const first = Number(normalize(match[1]));
|
||||
const second = Number(normalize(match[2]));
|
||||
const minimum = Math.min(first, second);
|
||||
const maximum = Math.max(first, second);
|
||||
return value >= minimum && value <= maximum;
|
||||
let compactRange = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/);
|
||||
const range = compactRange ? [compactRange[1], compactRange[2]] : rangeParts(term);
|
||||
if (range) {
|
||||
const first = parseFilterNumber(range[0]);
|
||||
const second = parseFilterNumber(range[1]);
|
||||
if (first !== null && second !== null) {
|
||||
const minimum = Math.min(first, second);
|
||||
const maximum = Math.max(first, second);
|
||||
return value >= minimum && value <= maximum;
|
||||
}
|
||||
}
|
||||
|
||||
const exact = Number(normalize(term));
|
||||
return Number.isFinite(exact) ? value === exact : false;
|
||||
if (!allowPlainExact) return null;
|
||||
const exact = parseFilterNumber(term);
|
||||
return exact === null ? null : value === exact;
|
||||
}
|
||||
|
||||
function dateFilterMatches(rawValue, rawTerm) {
|
||||
const value = parseFilterDate(rawValue);
|
||||
if (!value) return null;
|
||||
|
||||
const term = String(rawTerm || '').trim();
|
||||
if (!term) return true;
|
||||
|
||||
const nextDay = parsed => parsed.timestamp + 24 * 60 * 60 * 1000;
|
||||
let match = term.match(/^(<=|>=|<|>|=)\s*(.+)$/);
|
||||
if (match) {
|
||||
const expected = parseFilterDate(match[2]);
|
||||
if (!expected) return null;
|
||||
if (match[1] === '<') return value.timestamp < expected.timestamp;
|
||||
if (match[1] === '<=') return expected.dateOnly ? value.timestamp < nextDay(expected) : value.timestamp <= expected.timestamp;
|
||||
if (match[1] === '>') return expected.dateOnly ? value.timestamp >= nextDay(expected) : value.timestamp > expected.timestamp;
|
||||
if (match[1] === '>=') return value.timestamp >= expected.timestamp;
|
||||
if (expected.dateOnly) return value.timestamp >= expected.timestamp && value.timestamp < nextDay(expected);
|
||||
return value.timestamp === expected.timestamp;
|
||||
}
|
||||
|
||||
const range = rangeParts(term);
|
||||
if (range) {
|
||||
const first = parseFilterDate(range[0]);
|
||||
const second = parseFilterDate(range[1]);
|
||||
if (first && second) {
|
||||
const start = Math.min(first.timestamp, second.timestamp);
|
||||
const endSource = first.timestamp <= second.timestamp ? second : first;
|
||||
const end = endSource.dateOnly ? nextDay(endSource) : endSource.timestamp;
|
||||
return value.timestamp >= start && (endSource.dateOnly ? value.timestamp < end : value.timestamp <= end);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function advancedFilterMatches(cell, displayValue, term, definition) {
|
||||
const comparable = comparableCellValue(cell);
|
||||
const neutralTerm = String(term || '').trim().toLowerCase();
|
||||
if (neutralTerm === '@empty') return !String(comparable || '').trim();
|
||||
if (neutralTerm === '@notempty') return !!String(comparable || '').trim();
|
||||
|
||||
// Preserve the pre-existing software-summary numeric behavior exactly:
|
||||
// plain numeric input is an exact match in columns explicitly marked as numeric.
|
||||
if (definition.numericFilter) {
|
||||
const numeric = numericFilterMatches(comparable, term, true);
|
||||
return numeric === null ? searchTextMatches(displayValue, term) : numeric;
|
||||
}
|
||||
|
||||
// Everywhere else advanced comparisons are opt-in by syntax. A normal
|
||||
// filter such as "5" or "2026" therefore keeps the existing substring behavior.
|
||||
const numeric = numericFilterMatches(comparable, term, false);
|
||||
if (numeric !== null) return numeric;
|
||||
|
||||
const date = dateFilterMatches(comparable, term);
|
||||
if (date !== null) return date;
|
||||
|
||||
return searchTextMatches(displayValue, term);
|
||||
}
|
||||
|
||||
|
||||
function inferredAdvancedFilterType(header, columnIndex, serverName) {
|
||||
const explicit = String(header.dataset.filterType || '').toLowerCase();
|
||||
if (['integer', 'decimal', 'number'].includes(explicit)) return 'number';
|
||||
if (['date', 'datetime'].includes(explicit)) return 'date';
|
||||
if (header.dataset.numericFilter === '1') return 'number';
|
||||
if (['job_id', 'priority'].includes(serverName)) return 'number';
|
||||
if (serverName === 'created_at') return 'date';
|
||||
|
||||
// Generic fallback for tables that do not declare a data type. Only
|
||||
// classify a column when every sampled non-empty value is parseable.
|
||||
const samples = [...tbody.rows]
|
||||
.map(row => comparableCellValue(row.cells[columnIndex]))
|
||||
.filter(value => String(value || '').trim())
|
||||
.slice(0, 12);
|
||||
if (!samples.length) return '';
|
||||
if (samples.every(value => parseFilterDate(value))) return 'date';
|
||||
if (samples.every(value => parseFilterNumber(value) !== null)) return 'number';
|
||||
return '';
|
||||
}
|
||||
|
||||
function createAdvancedFilterHelper(filterCell, input, kind) {
|
||||
if (!kind) return;
|
||||
|
||||
filterCell.classList.add('advanced-filter-cell');
|
||||
input.classList.add('advanced-filter-input');
|
||||
|
||||
// Keep the popup outside the table. Asset tables use sticky headers and
|
||||
// overflow scrollers, which clip absolutely positioned descendants of TH.
|
||||
// A body-level fixed popup remains visible in every table layout.
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'advanced-filter-popup advanced-filter-popup-portal';
|
||||
popup.hidden = true;
|
||||
document.body.appendChild(popup);
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.className = 'advanced-filter-title';
|
||||
title.textContent = document.body.dataset.advancedFilterTitle || 'Advanced filter';
|
||||
popup.appendChild(title);
|
||||
|
||||
const operatorLabel = document.createElement('label');
|
||||
operatorLabel.textContent = document.body.dataset.advancedFilterOperator || 'Condition';
|
||||
const operator = document.createElement('select');
|
||||
const options = [
|
||||
['=', document.body.dataset.advancedFilterEqual || 'Equals'],
|
||||
['>', document.body.dataset.advancedFilterGt || 'Greater than'],
|
||||
['>=', document.body.dataset.advancedFilterGte || 'Greater than or equal'],
|
||||
['<', document.body.dataset.advancedFilterLt || 'Less than'],
|
||||
['<=', document.body.dataset.advancedFilterLte || 'Less than or equal'],
|
||||
['range', document.body.dataset.advancedFilterRange || 'Range'],
|
||||
['@empty', document.body.dataset.advancedFilterEmpty || 'Empty'],
|
||||
['@notempty', document.body.dataset.advancedFilterNotEmpty || 'Not empty']
|
||||
];
|
||||
options.forEach(([value, label]) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
operator.appendChild(option);
|
||||
});
|
||||
operatorLabel.appendChild(operator);
|
||||
popup.appendChild(operatorLabel);
|
||||
|
||||
const valueWrap = document.createElement('div');
|
||||
valueWrap.className = 'advanced-filter-values';
|
||||
const makeValueInput = (labelText, className) => {
|
||||
const label = document.createElement('label');
|
||||
label.textContent = labelText;
|
||||
const valueInput = document.createElement('input');
|
||||
valueInput.className = className;
|
||||
valueInput.type = kind === 'date' ? 'date' : 'number';
|
||||
if (kind === 'number') valueInput.step = 'any';
|
||||
label.appendChild(valueInput);
|
||||
valueWrap.appendChild(label);
|
||||
return {label, input: valueInput};
|
||||
};
|
||||
const first = makeValueInput(document.body.dataset.advancedFilterValue || 'Value', 'advanced-filter-value-first');
|
||||
const second = makeValueInput(document.body.dataset.advancedFilterValueTo || 'To', 'advanced-filter-value-second');
|
||||
second.label.hidden = true;
|
||||
popup.appendChild(valueWrap);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'advanced-filter-actions';
|
||||
const apply = document.createElement('button');
|
||||
apply.type = 'button';
|
||||
apply.className = 'button button-primary';
|
||||
apply.textContent = document.body.dataset.advancedFilterApply || 'Apply';
|
||||
const clear = document.createElement('button');
|
||||
clear.type = 'button';
|
||||
clear.className = 'button button-secondary';
|
||||
clear.textContent = document.body.dataset.advancedFilterClear || 'Clear';
|
||||
actions.append(apply, clear);
|
||||
popup.appendChild(actions);
|
||||
|
||||
const syncOperator = () => {
|
||||
const range = operator.value === 'range';
|
||||
const needsValue = !['@empty', '@notempty'].includes(operator.value);
|
||||
first.label.hidden = !needsValue;
|
||||
first.label.firstChild.textContent = range
|
||||
? (document.body.dataset.advancedFilterValueFrom || 'From')
|
||||
: (document.body.dataset.advancedFilterValue || 'Value');
|
||||
second.label.hidden = !range || !needsValue;
|
||||
};
|
||||
operator.addEventListener('change', syncOperator);
|
||||
syncOperator();
|
||||
|
||||
const positionPopup = () => {
|
||||
if (popup.hidden) return;
|
||||
const rect = input.getBoundingClientRect();
|
||||
const margin = 6;
|
||||
const minWidth = Math.max(250, Math.round(rect.width));
|
||||
popup.style.minWidth = `${minWidth}px`;
|
||||
popup.style.maxWidth = `min(360px, calc(100vw - ${margin * 2}px))`;
|
||||
|
||||
// Measure after making the popup visible, then choose below/above.
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
let left = rect.left;
|
||||
if (left + popupRect.width > window.innerWidth - margin) {
|
||||
left = Math.max(margin, window.innerWidth - popupRect.width - margin);
|
||||
}
|
||||
left = Math.max(margin, left);
|
||||
|
||||
let top = rect.bottom + 4;
|
||||
if (top + popupRect.height > window.innerHeight - margin && rect.top > popupRect.height + margin) {
|
||||
top = rect.top - popupRect.height - 4;
|
||||
}
|
||||
popup.style.left = `${Math.round(left)}px`;
|
||||
popup.style.top = `${Math.round(Math.max(margin, top))}px`;
|
||||
};
|
||||
|
||||
const openPopup = () => {
|
||||
popup.hidden = false;
|
||||
popup.dataset.open = '1';
|
||||
positionPopup();
|
||||
};
|
||||
const closePopup = () => {
|
||||
popup.hidden = true;
|
||||
delete popup.dataset.open;
|
||||
};
|
||||
|
||||
// Focus/click both open the helper. Click is kept explicitly because a
|
||||
// textarea can already be focused when the user clicks it again.
|
||||
input.addEventListener('focus', openPopup);
|
||||
input.addEventListener('click', openPopup);
|
||||
popup.addEventListener('click', event => event.stopPropagation());
|
||||
popup.addEventListener('pointerdown', event => event.stopPropagation());
|
||||
document.addEventListener('click', event => {
|
||||
if (event.target === input || popup.contains(event.target)) return;
|
||||
closePopup();
|
||||
});
|
||||
window.addEventListener('resize', positionPopup);
|
||||
// Reposition while any surrounding table/page scroller moves.
|
||||
window.addEventListener('scroll', positionPopup, true);
|
||||
|
||||
const triggerFilter = () => {
|
||||
input.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
};
|
||||
|
||||
apply.addEventListener('click', () => {
|
||||
if (['@empty', '@notempty'].includes(operator.value)) {
|
||||
input.value = operator.value;
|
||||
triggerFilter();
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
const value1 = first.input.value.trim();
|
||||
if (!value1) return;
|
||||
if (operator.value === 'range') {
|
||||
const value2 = second.input.value.trim();
|
||||
if (!value2) return;
|
||||
input.value = `${value1}..${value2}`;
|
||||
} else {
|
||||
input.value = `${operator.value}${value1}`;
|
||||
}
|
||||
triggerFilter();
|
||||
closePopup();
|
||||
});
|
||||
|
||||
clear.addEventListener('click', () => {
|
||||
first.input.value = '';
|
||||
second.input.value = '';
|
||||
input.value = '';
|
||||
triggerFilter();
|
||||
closePopup();
|
||||
});
|
||||
|
||||
[first.input, second.input].forEach(valueInput => {
|
||||
valueInput.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
apply.click();
|
||||
} else if (event.key === 'Escape') {
|
||||
closePopup();
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function submitServerFilters(immediate = false) {
|
||||
if (!usesServerFilters) return;
|
||||
@@ -743,12 +1070,9 @@
|
||||
const hidden = active.some(({ definition, terms }) => {
|
||||
const cell = cellForDefinition(row, definition);
|
||||
const value = textValue(cell);
|
||||
const matchesAnyTerm = terms.some(term => {
|
||||
if (definition.numericFilter) {
|
||||
return numericFilterMatches(value, term);
|
||||
}
|
||||
return searchTextMatches(value, term);
|
||||
});
|
||||
const matchesAnyTerm = terms.some(term =>
|
||||
advancedFilterMatches(cell, value, term, definition)
|
||||
);
|
||||
return !matchesAnyTerm;
|
||||
});
|
||||
|
||||
@@ -849,12 +1173,15 @@
|
||||
}
|
||||
|
||||
filterCell.appendChild(input);
|
||||
const advancedFilterType = inferredAdvancedFilterType(header, columnIndex, serverName);
|
||||
createAdvancedFilterHelper(filterCell, input, advancedFilterType);
|
||||
filterDefinitions.push({
|
||||
field,
|
||||
columnIndex,
|
||||
input,
|
||||
serverName,
|
||||
numericFilter: header.dataset.numericFilter === '1'
|
||||
numericFilter: header.dataset.numericFilter === '1',
|
||||
advancedFilterType
|
||||
});
|
||||
}
|
||||
|
||||
@@ -878,6 +1205,7 @@
|
||||
});
|
||||
|
||||
table.assetApplyFilters = applyFilters;
|
||||
table.assetSubmitServerFilters = submitServerFilters;
|
||||
table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options);
|
||||
table.assetUpdateRowNumbers = updateRowNumbers;
|
||||
table.assetRebuildFilterCache = rebuildMissingCellKeys;
|
||||
@@ -916,10 +1244,9 @@
|
||||
|
||||
const headerRow = thead.rows[0];
|
||||
const storagePart = table.dataset.storageKey || table.id || `${window.location.pathname}:table-${[...document.querySelectorAll('main table')].indexOf(table)}`;
|
||||
const storagePrefix = `assetmanager:table:${storagePart}:column-width:`;
|
||||
const storagePrefix = `table:${storagePart}:column-width:`;
|
||||
const minWidth = 56;
|
||||
const maxWidth = 1200;
|
||||
const normalize = width => Math.min(maxWidth, Math.max(minWidth, Math.round(width)));
|
||||
const normalize = width => Math.max(minWidth, Math.round(width));
|
||||
|
||||
const columnKey = (header, index) => header.dataset.field || `index-${index}`;
|
||||
const applyWidth = (index, width) => {
|
||||
@@ -935,27 +1262,33 @@
|
||||
};
|
||||
|
||||
const freezeGeometry = () => {
|
||||
const widths = [...headerRow.cells].map(cell => normalize(cell.getBoundingClientRect().width || minWidth));
|
||||
const widths = [...headerRow.cells].map(cell => {
|
||||
const measured = Math.round(cell.getBoundingClientRect().width || 0);
|
||||
return measured > 0 ? normalize(measured) : 0;
|
||||
});
|
||||
const total = widths.reduce((sum, width) => sum + width, 0);
|
||||
widths.forEach((width, index) => applyWidth(index, width));
|
||||
widths.forEach((width, index) => { if (width) applyWidth(index, width); });
|
||||
table.style.tableLayout = 'fixed';
|
||||
table.style.width = `${total}px`;
|
||||
table.style.minWidth = `${total}px`;
|
||||
};
|
||||
|
||||
const setTableWidth = total => {
|
||||
const normalized = Math.max(1, Math.round(total || 0));
|
||||
table.style.width = `${normalized}px`;
|
||||
table.style.minWidth = `${normalized}px`;
|
||||
};
|
||||
const currentTableWidth = () => [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0);
|
||||
const syncTableWidth = () => {
|
||||
const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0);
|
||||
if (total > 0) {
|
||||
table.style.width = `${total}px`;
|
||||
table.style.minWidth = `${total}px`;
|
||||
}
|
||||
const total = currentTableWidth();
|
||||
if (total > 0) setTableWidth(total);
|
||||
};
|
||||
|
||||
[...headerRow.cells].forEach((header, index) => {
|
||||
if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return;
|
||||
const key = `${storagePrefix}${columnKey(header, index)}`;
|
||||
try {
|
||||
const stored = Number.parseInt(localStorage.getItem(key) || '', 10);
|
||||
const stored = Number.parseInt(stateGet(key) || '', 10);
|
||||
if (Number.isFinite(stored)) applyWidth(index, stored);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -971,44 +1304,28 @@
|
||||
|
||||
const save = width => {
|
||||
const normalized = applyWidth(index, width);
|
||||
try { localStorage.setItem(key, String(normalized)); } catch (_) {}
|
||||
try { stateSet(key, String(normalized)); } catch (_) {}
|
||||
};
|
||||
handle.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); });
|
||||
handle.addEventListener('pointerdown', event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
freezeGeometry();
|
||||
const headers = [...headerRow.cells];
|
||||
const rightmostIndex = headers.length - 1;
|
||||
const currentIndex = header.cellIndex;
|
||||
const startWidth = header.getBoundingClientRect().width || minWidth;
|
||||
const rightmostHeader = headers[rightmostIndex];
|
||||
const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minWidth;
|
||||
const keepTableWidth = index !== rightmostIndex;
|
||||
const startTableWidth = currentTableWidth();
|
||||
document.documentElement.classList.add('table-column-resizing');
|
||||
|
||||
const resizePair = delta => {
|
||||
if (!keepTableWidth) {
|
||||
applyWidth(index, startWidth + delta);
|
||||
syncTableWidth();
|
||||
return;
|
||||
}
|
||||
const minDelta = minWidth - startWidth;
|
||||
const maxDelta = rightmostStartWidth - minWidth;
|
||||
const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta));
|
||||
applyWidth(index, startWidth + effectiveDelta);
|
||||
applyWidth(rightmostIndex, rightmostStartWidth - effectiveDelta);
|
||||
const resizeColumn = delta => {
|
||||
const newWidth = applyWidth(currentIndex, startWidth + delta);
|
||||
setTableWidth(startTableWidth + (newWidth - startWidth));
|
||||
};
|
||||
|
||||
const move = e => resizePair(e.clientX - startX);
|
||||
const move = e => resizeColumn(e.clientX - startX);
|
||||
const finish = () => {
|
||||
save(header.getBoundingClientRect().width || startWidth);
|
||||
if (keepTableWidth && rightmostHeader) {
|
||||
const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`;
|
||||
const rightWidth = applyWidth(rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth);
|
||||
try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {}
|
||||
} else {
|
||||
syncTableWidth();
|
||||
}
|
||||
const normalized = applyWidth(currentIndex, header.getBoundingClientRect().width || startWidth);
|
||||
try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {}
|
||||
syncTableWidth();
|
||||
document.documentElement.classList.remove('table-column-resizing');
|
||||
window.removeEventListener('pointermove', move, true);
|
||||
window.removeEventListener('pointerup', finish, true);
|
||||
@@ -1022,33 +1339,23 @@
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
freezeGeometry();
|
||||
const headers = [...headerRow.cells];
|
||||
const rightmostIndex = headers.length - 1;
|
||||
const currentIndex = header.cellIndex;
|
||||
const step = event.key === 'ArrowRight' ? 12 : -12;
|
||||
const current = header.getBoundingClientRect().width || minWidth;
|
||||
if (index === rightmostIndex) {
|
||||
save(current + step);
|
||||
syncTableWidth();
|
||||
return;
|
||||
}
|
||||
const rightmostHeader = headers[rightmostIndex];
|
||||
const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minWidth;
|
||||
const minDelta = minWidth - current;
|
||||
const maxDelta = rightmostWidth - minWidth;
|
||||
const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step));
|
||||
save(current + effectiveDelta);
|
||||
if (rightmostHeader) {
|
||||
const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`;
|
||||
const rightWidth = applyWidth(rightmostIndex, rightmostWidth - effectiveDelta);
|
||||
try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {}
|
||||
}
|
||||
const normalized = applyWidth(currentIndex, current + step);
|
||||
try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {}
|
||||
syncTableWidth();
|
||||
});
|
||||
});
|
||||
table.dataset.columnResizeReady = '1';
|
||||
}
|
||||
|
||||
document.querySelectorAll('table.data-table').forEach(initTable);
|
||||
document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize);
|
||||
const initializeTableTools = () => {
|
||||
document.querySelectorAll('table.data-table').forEach(initTable);
|
||||
document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize);
|
||||
};
|
||||
if (uiState?.ready) uiState.ready.finally(initializeTableTools);
|
||||
else initializeTableTools();
|
||||
|
||||
window.addEventListener('asset-table-content-reloaded', event => {
|
||||
const table = event.detail?.table;
|
||||
|
||||
Reference in New Issue
Block a user