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