Files
client/www/js/resizable-columns.mjs
T
2026-04-17 16:52:51 -04:00

245 lines
8.4 KiB
JavaScript

/**
* Initialize draggable resizable grid/flex-adjacent tracks using CSS variables.
*
* Usage guardrails:
* - Panels that occupy each resizable track should use `width: 100%` (or `height: 100%` for vertical mode)
* so panel box width follows the track width.
* - Track CSS variables are best expressed in `px`, but non-px values are tolerated via rendered-size fallback.
*/
export function initResizableColumns(options = {}) {
const {
container,
columnVarNames = [],
minWidths = [],
gutterSelector = '.resizableColumnsGutter[data-gutter-index]',
compactMediaQuery = '(max-width: 860px)',
gutterSizeVarName = '--vj-gutter-size',
defaultGutterSize = 10,
onWidthsChange = null,
initialWidths = null,
resizeDebounceMs = 120,
axis = 'x',
} = options;
const appEl = typeof container === 'string'
? document.querySelector(container)
: container;
if (!appEl) {
throw new Error('initResizableColumns: container element was not found');
}
if (!Array.isArray(columnVarNames) || !columnVarNames.length) {
throw new Error('initResizableColumns: columnVarNames must be a non-empty array');
}
if (!Array.isArray(minWidths) || minWidths.length !== columnVarNames.length) {
throw new Error('initResizableColumns: minWidths must match columnVarNames length');
}
const parsePxValue = (value) => {
const num = Number.parseFloat(String(value || '').trim().replace('px', ''));
return Number.isFinite(num) ? num : 0;
};
const isCompactLayout = () => window.matchMedia(compactMediaQuery).matches;
const getGutters = () => Array.from(appEl.querySelectorAll(gutterSelector));
const isVertical = axis === 'y';
const getAvailableTrackSize = () => {
const styles = window.getComputedStyle(appEl);
const gutterSize = parsePxValue(styles.getPropertyValue(gutterSizeVarName)) || defaultGutterSize;
const guttersTotal = gutterSize * getGutters().length;
const containerSize = isVertical ? appEl.clientHeight : appEl.clientWidth;
const paddingStart = isVertical
? parsePxValue(styles.paddingTop)
: parsePxValue(styles.paddingLeft);
const paddingEnd = isVertical
? parsePxValue(styles.paddingBottom)
: parsePxValue(styles.paddingRight);
const contentTrackSize = Math.max(0, containerSize - paddingStart - paddingEnd);
return Math.max(0, contentTrackSize - guttersTotal);
};
const getRenderedTrackSizes = () => {
const children = Array.from(appEl.children).filter((child) => {
if (!(child instanceof Element)) return false;
return !child.matches(gutterSelector);
});
if (children.length < columnVarNames.length) return null;
const sizes = children.slice(0, columnVarNames.length).map((child) => {
const rect = child.getBoundingClientRect();
const size = isVertical ? rect.height : rect.width;
return Number.isFinite(size) ? Math.round(size) : 0;
});
return sizes.every((size) => size > 0) ? sizes : null;
};
const getWidths = () => {
const styles = window.getComputedStyle(appEl);
const renderedTrackSizes = getRenderedTrackSizes();
return columnVarNames.map((varName, index) => {
const rawValue = String(styles.getPropertyValue(varName) || '').trim();
const isPxValue = /^-?\d+(?:\.\d+)?px$/i.test(rawValue);
const cssValue = isPxValue ? parsePxValue(rawValue) : 0;
if (cssValue > 0) return cssValue;
const renderedValue = renderedTrackSizes?.[index] || 0;
return renderedValue > 0 ? renderedValue : minWidths[index];
});
};
const normalizeWidths = (widths) => {
if (!Array.isArray(widths) || widths.length !== columnVarNames.length) {
return getWidths();
}
const available = getAvailableTrackSize();
const base = widths.map((width, index) => Math.max(minWidths[index], Math.round(Number(width) || 0)));
if (available <= 0) return base;
let sum = base.reduce((acc, n) => acc + n, 0);
if (sum <= 0) return getWidths();
let normalized = base.map((width) => Math.max(0, Math.round((width / sum) * available)));
normalized = normalized.map((width, index) => Math.max(minWidths[index], width));
sum = normalized.reduce((acc, n) => acc + n, 0);
let diff = available - sum;
if (diff > 0) {
let cursor = 0;
while (diff > 0) {
normalized[cursor % normalized.length] += 1;
diff -= 1;
cursor += 1;
}
} else if (diff < 0) {
let remaining = Math.abs(diff);
while (remaining > 0) {
let reducedAtLeastOne = false;
for (let i = normalized.length - 1; i >= 0 && remaining > 0; i -= 1) {
if (normalized[i] > minWidths[i]) {
normalized[i] -= 1;
remaining -= 1;
reducedAtLeastOne = true;
}
}
if (!reducedAtLeastOne) break;
}
}
return normalized;
};
const setWidths = (widths, { persist = false } = {}) => {
if (isCompactLayout()) return;
const normalized = normalizeWidths(widths);
normalized.forEach((width, index) => {
appEl.style.setProperty(columnVarNames[index], `${width}px`);
});
if (typeof onWidthsChange === 'function') {
onWidthsChange(normalized, { persist });
}
};
const teardownFns = [];
const gutters = getGutters();
gutters.forEach((gutter) => {
gutter.dataset.axis = isVertical ? 'y' : 'x';
const onPointerDown = (event) => {
if (event.button !== 0 || isCompactLayout()) return;
const gutterIndex = Number.parseInt(String(gutter.dataset.gutterIndex || ''), 10);
if (!Number.isInteger(gutterIndex) || gutterIndex < 0 || gutterIndex >= columnVarNames.length - 1) {
return;
}
const startWidths = getWidths();
const leftStart = startWidths[gutterIndex];
const rightStart = startWidths[gutterIndex + 1];
const pairTotal = leftStart + rightStart;
const minLeft = minWidths[gutterIndex];
const minRight = minWidths[gutterIndex + 1];
const startPointerValue = isVertical ? event.clientY : event.clientX;
gutter.classList.add('dragging');
document.body.style.cursor = isVertical ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (moveEvent) => {
const nextPointerValue = isVertical ? moveEvent.clientY : moveEvent.clientX;
const delta = nextPointerValue - startPointerValue;
let nextLeft = Math.round(leftStart + delta);
nextLeft = Math.max(minLeft, Math.min(nextLeft, pairTotal - minRight));
const nextRight = pairTotal - nextLeft;
const nextWidths = [...startWidths];
nextWidths[gutterIndex] = nextLeft;
nextWidths[gutterIndex + 1] = nextRight;
setWidths(nextWidths, { persist: false });
};
const finishDrag = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', finishDrag);
window.removeEventListener('pointercancel', finishDrag);
gutter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
setWidths(getWidths(), { persist: true });
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', finishDrag);
window.addEventListener('pointercancel', finishDrag);
};
gutter.addEventListener('pointerdown', onPointerDown);
teardownFns.push(() => {
gutter.removeEventListener('pointerdown', onPointerDown);
delete gutter.dataset.axis;
});
});
let resizeDebounce = null;
const onResize = () => {
if (resizeDebounce) clearTimeout(resizeDebounce);
resizeDebounce = setTimeout(() => {
if (isCompactLayout()) return;
setWidths(getWidths(), { persist: false });
}, resizeDebounceMs);
};
window.addEventListener('resize', onResize);
teardownFns.push(() => {
window.removeEventListener('resize', onResize);
if (resizeDebounce) clearTimeout(resizeDebounce);
});
if (!isCompactLayout()) {
const hasInitialWidths = Array.isArray(initialWidths) && initialWidths.length === columnVarNames.length;
const seedWidths = hasInitialWidths ? initialWidths : getWidths();
setWidths(seedWidths, { persist: false });
}
return {
getWidths,
setWidths,
normalizeWidths,
destroy() {
teardownFns.forEach((fn) => {
try {
fn();
} catch {
// Ignore teardown failures
}
});
},
};
}