feat: add reusable resizable split panes
This commit is contained in:
@@ -343,6 +343,55 @@ p {
|
|||||||
padding: 28px;
|
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 {
|
.empty-state {
|
||||||
width: min(680px, 100%);
|
width: min(680px, 100%);
|
||||||
padding: clamp(28px, 6vw, 56px);
|
padding: clamp(28px, 6vw, 56px);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ try {
|
|||||||
await import("/tests/unit/evaluation.test.js");
|
await import("/tests/unit/evaluation.test.js");
|
||||||
await import("/tests/unit/camera.test.js");
|
await import("/tests/unit/camera.test.js");
|
||||||
await import("/tests/unit/webgpu.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/cpu-baseline.test.js");
|
||||||
await import("/tests/integration/webgpu.test.js");
|
await import("/tests/integration/webgpu.test.js");
|
||||||
await import("/tests/integration/server.test.js");
|
await import("/tests/integration/server.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",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user