diff --git a/packages/ui/app-shell.css b/packages/ui/app-shell.css index f5387cc..22e4340 100644 --- a/packages/ui/app-shell.css +++ b/packages/ui/app-shell.css @@ -343,6 +343,55 @@ p { padding: 28px; } +.split-handle { + position: relative; + z-index: 12; + width: 12px; + min-width: 12px; + align-self: stretch; + border: 0; + cursor: col-resize; + touch-action: none; +} + +.split-handle::before { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 2px; + background: var(--line); + content: ""; + transform: translateX(-50%); + transition: + width 140ms ease, + background 140ms ease, + box-shadow 140ms ease; +} + +.split-handle:hover::before, +.split-handle:focus-visible::before, +[data-split-pane-dragging="true"] > .split-handle::before { + width: 4px; + background: var(--mint); + box-shadow: 0 0 18px rgb(101 242 191 / 34%); +} + +.split-handle:focus-visible { + outline: 2px solid var(--mint); + outline-offset: -2px; +} + +.split-handle[aria-disabled="true"] { + cursor: default; +} + +body.is-resizing-pane, +body.is-resizing-pane * { + cursor: col-resize !important; + user-select: none !important; +} + .empty-state { width: min(680px, 100%); padding: clamp(28px, 6vw, 56px); diff --git a/packages/ui/split-pane.js b/packages/ui/split-pane.js new file mode 100644 index 0000000..e2972b7 --- /dev/null +++ b/packages/ui/split-pane.js @@ -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); + }, + }; +} diff --git a/tests/test-runner.js b/tests/test-runner.js index 4c38dc2..706aa08 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -18,6 +18,7 @@ try { await import("/tests/unit/evaluation.test.js"); await import("/tests/unit/camera.test.js"); await import("/tests/unit/webgpu.test.js"); + await import("/tests/unit/split-pane.test.js"); await import("/tests/integration/cpu-baseline.test.js"); await import("/tests/integration/webgpu.test.js"); await import("/tests/integration/server.test.js"); diff --git a/tests/unit/split-pane.test.js b/tests/unit/split-pane.test.js new file mode 100644 index 0000000..b66a241 --- /dev/null +++ b/tests/unit/split-pane.test.js @@ -0,0 +1,41 @@ +import { + clampSplitPaneSize, + splitPaneSizeFromPointer, +} from "/packages/ui/split-pane.js"; + +test("분할 패널 폭이 설정 범위와 작업 영역 최소 폭을 지킨다", () => { + const metrics = { + containerSize: 1400, + separatorSize: 12, + minSize: 360, + maxSize: 720, + minMainSize: 560, + }; + assertEqual(clampSplitPaneSize(620, metrics), 620, "requested width"); + assertEqual(clampSplitPaneSize(900, metrics), 720, "hard maximum"); + assertEqual(clampSplitPaneSize(200, metrics), 360, "configured minimum"); + assertEqual( + clampSplitPaneSize(720, { ...metrics, containerSize: 981 }), + 409, + "workspace-aware maximum", + ); +}); + +test("분리선 pointer 좌표가 시작·끝 패널 폭으로 변환된다", () => { + const common = { + pointerX: 506, + containerLeft: 100, + containerRight: 1500, + separatorSize: 12, + }; + assertEqual( + splitPaneSizeFromPointer({ ...common, panelSide: "start" }), + 400, + "start panel width", + ); + assertEqual( + splitPaneSizeFromPointer({ ...common, panelSide: "end" }), + 988, + "end panel width", + ); +});