245 lines
8.4 KiB
JavaScript
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
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|