Files

346 lines
11 KiB
JavaScript

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);
},
};
}