feat: add reusable resizable split panes
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
const DEFAULT_KEYBOARD_STEP = 16;
|
||||
|
||||
function finiteNumber(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function splitPaneRange({
|
||||
containerSize,
|
||||
separatorSize = 0,
|
||||
minSize,
|
||||
maxSize,
|
||||
minMainSize = 0,
|
||||
}) {
|
||||
const minimum = Math.max(0, finiteNumber(minSize, 0));
|
||||
const maximum = Math.max(minimum, finiteNumber(maxSize, minimum));
|
||||
const available = Number.isFinite(containerSize)
|
||||
? Math.max(0, containerSize - Math.max(0, separatorSize) - Math.max(0, minMainSize))
|
||||
: maximum;
|
||||
const effectiveMaximum = Math.min(maximum, available);
|
||||
return {
|
||||
min: Math.min(minimum, effectiveMaximum),
|
||||
max: effectiveMaximum,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampSplitPaneSize(requested, metrics) {
|
||||
const range = splitPaneRange(metrics);
|
||||
const fallback = range.min;
|
||||
const value = finiteNumber(requested, fallback);
|
||||
return Math.min(range.max, Math.max(range.min, value));
|
||||
}
|
||||
|
||||
export function splitPaneSizeFromPointer({
|
||||
pointerX,
|
||||
containerLeft,
|
||||
containerRight,
|
||||
separatorSize = 0,
|
||||
panelSide,
|
||||
}) {
|
||||
const halfSeparator = Math.max(0, separatorSize) / 2;
|
||||
if (panelSide === "start") {
|
||||
return pointerX - containerLeft - halfSeparator;
|
||||
}
|
||||
if (panelSide === "end") {
|
||||
return containerRight - pointerX - halfSeparator;
|
||||
}
|
||||
throw new TypeError('panelSide는 "start" 또는 "end"여야 합니다.');
|
||||
}
|
||||
|
||||
function requireElement(value, name) {
|
||||
if (!(value instanceof Element)) {
|
||||
throw new TypeError(name + " 요소가 필요합니다.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createSplitPane(options) {
|
||||
const container = requireElement(options?.container, "container");
|
||||
const separator = requireElement(options?.separator, "separator");
|
||||
const panel = requireElement(options?.panel, "panel");
|
||||
const panelSide = options.panelSide;
|
||||
if (panelSide !== "start" && panelSide !== "end") {
|
||||
throw new TypeError('panelSide는 "start" 또는 "end"여야 합니다.');
|
||||
}
|
||||
|
||||
const ownerDocument = container.ownerDocument;
|
||||
const view = ownerDocument.defaultView;
|
||||
if (!view) throw new Error("분할 패널 window를 찾을 수 없습니다.");
|
||||
|
||||
const cssVariable = options.cssVariable ?? "--control-panel-width";
|
||||
const storageKey = String(options.storageKey ?? "");
|
||||
const defaultSize = Math.max(0, finiteNumber(options.defaultSize, 400));
|
||||
const minSize = Math.max(0, finiteNumber(options.minSize, 320));
|
||||
const maxSize = Math.max(minSize, finiteNumber(options.maxSize, 720));
|
||||
const minMainSize = Math.max(0, finiteNumber(options.minMainSize, 520));
|
||||
const keyboardStep = Math.max(1, finiteNumber(options.keyboardStep, DEFAULT_KEYBOARD_STEP));
|
||||
const enabledMedia = options.enabledMediaQuery
|
||||
? view.matchMedia(options.enabledMediaQuery)
|
||||
: null;
|
||||
|
||||
let storage = null;
|
||||
try {
|
||||
storage = Object.hasOwn(options, "storage") ? options.storage : view.localStorage;
|
||||
} catch {
|
||||
storage = null;
|
||||
}
|
||||
|
||||
let requestedSize = defaultSize;
|
||||
if (storage && storageKey) {
|
||||
try {
|
||||
const stored = Number(storage.getItem(storageKey));
|
||||
if (Number.isFinite(stored) && stored > 0) requestedSize = stored;
|
||||
} catch {
|
||||
// UI preference storage must never prevent the app from starting.
|
||||
}
|
||||
}
|
||||
|
||||
let currentSize = defaultSize;
|
||||
let activePointerId = null;
|
||||
let pendingPointerSize = null;
|
||||
let pointerFrame = 0;
|
||||
let resizeFrame = 0;
|
||||
let destroyed = false;
|
||||
|
||||
separator.dataset.storageKey = storageKey;
|
||||
|
||||
const isEnabled = () => enabledMedia?.matches ?? true;
|
||||
|
||||
function measurements() {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const separatorRect = separator.getBoundingClientRect();
|
||||
const containerSize = containerRect.width || container.clientWidth;
|
||||
return {
|
||||
containerSize: containerSize > 0 ? containerSize : Number.POSITIVE_INFINITY,
|
||||
separatorSize: separatorRect.width || separator.offsetWidth || 0,
|
||||
minSize,
|
||||
maxSize,
|
||||
minMainSize,
|
||||
};
|
||||
}
|
||||
|
||||
function updateAccessibility(size, metrics = measurements()) {
|
||||
const range = splitPaneRange(metrics);
|
||||
const rounded = Math.round(size);
|
||||
separator.setAttribute("aria-valuemin", String(Math.round(range.min)));
|
||||
separator.setAttribute("aria-valuemax", String(Math.round(range.max)));
|
||||
separator.setAttribute("aria-valuenow", String(rounded));
|
||||
separator.setAttribute("aria-valuetext", "설정 패널 " + rounded + "px");
|
||||
}
|
||||
|
||||
function applySize(value, { updateRequested = false, emit = true } = {}) {
|
||||
if (destroyed) return currentSize;
|
||||
const metrics = measurements();
|
||||
const nextSize = clampSplitPaneSize(value, metrics);
|
||||
const changed = Math.abs(nextSize - currentSize) >= 0.5;
|
||||
currentSize = nextSize;
|
||||
if (updateRequested) requestedSize = nextSize;
|
||||
container.style.setProperty(cssVariable, Math.round(nextSize) + "px");
|
||||
container.dataset.splitPaneSize = String(Math.round(nextSize));
|
||||
updateAccessibility(nextSize, metrics);
|
||||
if (emit && changed) {
|
||||
container.dispatchEvent(
|
||||
new view.CustomEvent("splitpanechange", {
|
||||
detail: { panelSide, size: nextSize },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return nextSize;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!storage || !storageKey) return;
|
||||
try {
|
||||
storage.setItem(storageKey, String(Math.round(currentSize)));
|
||||
} catch {
|
||||
// Private browsing and quota failures should only disable persistence.
|
||||
}
|
||||
}
|
||||
|
||||
function flushPointerSize() {
|
||||
if (pointerFrame) {
|
||||
view.cancelAnimationFrame(pointerFrame);
|
||||
pointerFrame = 0;
|
||||
}
|
||||
if (pendingPointerSize === null) return;
|
||||
const next = pendingPointerSize;
|
||||
pendingPointerSize = null;
|
||||
applySize(next, { updateRequested: true });
|
||||
}
|
||||
|
||||
function schedulePointerSize(value) {
|
||||
pendingPointerSize = value;
|
||||
if (pointerFrame) return;
|
||||
pointerFrame = view.requestAnimationFrame(() => {
|
||||
pointerFrame = 0;
|
||||
if (pendingPointerSize === null) return;
|
||||
const next = pendingPointerSize;
|
||||
pendingPointerSize = null;
|
||||
applySize(next, { updateRequested: true });
|
||||
});
|
||||
}
|
||||
|
||||
function stopDragging({ save = true } = {}) {
|
||||
if (activePointerId === null) return;
|
||||
flushPointerSize();
|
||||
try {
|
||||
if (separator.hasPointerCapture?.(activePointerId)) {
|
||||
separator.releasePointerCapture(activePointerId);
|
||||
}
|
||||
} catch {
|
||||
// Synthetic events and interrupted pointers might not own capture.
|
||||
}
|
||||
activePointerId = null;
|
||||
container.dataset.splitPaneDragging = "false";
|
||||
ownerDocument.body.classList.remove("is-resizing-pane");
|
||||
if (save) persist();
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (!isEnabled() || event.button !== 0 || activePointerId !== null) return;
|
||||
activePointerId = event.pointerId;
|
||||
container.dataset.splitPaneDragging = "true";
|
||||
ownerDocument.body.classList.add("is-resizing-pane");
|
||||
try {
|
||||
separator.setPointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// Synthetic PointerEvents do not always create an active pointer.
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (event.pointerId !== activePointerId) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const separatorRect = separator.getBoundingClientRect();
|
||||
schedulePointerSize(
|
||||
splitPaneSizeFromPointer({
|
||||
pointerX: event.clientX,
|
||||
containerLeft: containerRect.left,
|
||||
containerRight: containerRect.right,
|
||||
separatorSize: separatorRect.width,
|
||||
panelSide,
|
||||
}),
|
||||
);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerEnd(event) {
|
||||
if (event.pointerId !== activePointerId) return;
|
||||
stopDragging();
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (!isEnabled()) return;
|
||||
const multiplier = event.shiftKey ? 4 : 1;
|
||||
const step = keyboardStep * multiplier;
|
||||
let nextSize = null;
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextSize = currentSize + (panelSide === "end" ? step : -step);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextSize = currentSize + (panelSide === "start" ? step : -step);
|
||||
} else if (event.key === "Home") {
|
||||
nextSize = minSize;
|
||||
} else if (event.key === "End") {
|
||||
nextSize = maxSize;
|
||||
} else if (event.key === "Enter") {
|
||||
reset();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (nextSize === null) return;
|
||||
applySize(nextSize, { updateRequested: true });
|
||||
persist();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
requestedSize = defaultSize;
|
||||
applySize(defaultSize, { updateRequested: true });
|
||||
if (storage && storageKey) {
|
||||
try {
|
||||
storage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Ignore inaccessible preference storage.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncEnabledState() {
|
||||
const enabled = isEnabled();
|
||||
container.dataset.splitPaneActive = String(enabled);
|
||||
separator.setAttribute("aria-disabled", String(!enabled));
|
||||
if (!enabled) {
|
||||
stopDragging({ save: false });
|
||||
return;
|
||||
}
|
||||
applySize(requestedSize, { emit: false });
|
||||
}
|
||||
|
||||
function scheduleContainerSync() {
|
||||
if (!isEnabled() || resizeFrame || destroyed) return;
|
||||
resizeFrame = view.requestAnimationFrame(() => {
|
||||
resizeFrame = 0;
|
||||
applySize(requestedSize, { emit: false });
|
||||
});
|
||||
}
|
||||
|
||||
function onDoubleClick(event) {
|
||||
if (!isEnabled()) return;
|
||||
reset();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
separator.addEventListener("pointerdown", onPointerDown);
|
||||
separator.addEventListener("keydown", onKeyDown);
|
||||
separator.addEventListener("dblclick", onDoubleClick);
|
||||
view.addEventListener("pointermove", onPointerMove);
|
||||
view.addEventListener("pointerup", onPointerEnd);
|
||||
view.addEventListener("pointercancel", onPointerEnd);
|
||||
view.addEventListener("blur", stopDragging);
|
||||
view.addEventListener("resize", scheduleContainerSync);
|
||||
|
||||
const resizeObserver = typeof view.ResizeObserver === "function"
|
||||
? new view.ResizeObserver(scheduleContainerSync)
|
||||
: null;
|
||||
resizeObserver?.observe(container);
|
||||
|
||||
const onMediaChange = () => syncEnabledState();
|
||||
if (enabledMedia?.addEventListener) enabledMedia.addEventListener("change", onMediaChange);
|
||||
else enabledMedia?.addListener?.(onMediaChange);
|
||||
|
||||
syncEnabledState();
|
||||
|
||||
return {
|
||||
get size() {
|
||||
return currentSize;
|
||||
},
|
||||
setSize(value, { save = true } = {}) {
|
||||
requestedSize = finiteNumber(value, currentSize);
|
||||
const applied = applySize(requestedSize, { updateRequested: true });
|
||||
if (save) persist();
|
||||
return applied;
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
stopDragging({ save: false });
|
||||
destroyed = true;
|
||||
separator.removeEventListener("pointerdown", onPointerDown);
|
||||
separator.removeEventListener("keydown", onKeyDown);
|
||||
separator.removeEventListener("dblclick", onDoubleClick);
|
||||
view.removeEventListener("pointermove", onPointerMove);
|
||||
view.removeEventListener("pointerup", onPointerEnd);
|
||||
view.removeEventListener("pointercancel", onPointerEnd);
|
||||
view.removeEventListener("blur", stopDragging);
|
||||
view.removeEventListener("resize", scheduleContainerSync);
|
||||
resizeObserver?.disconnect();
|
||||
if (enabledMedia?.removeEventListener) enabledMedia.removeEventListener("change", onMediaChange);
|
||||
else enabledMedia?.removeListener?.(onMediaChange);
|
||||
if (pointerFrame) view.cancelAnimationFrame(pointerFrame);
|
||||
if (resizeFrame) view.cancelAnimationFrame(resizeFrame);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user