Files
Aislo/ui_template/ui_template_resizer.ts
T
eomsangdonandClaude Opus 5 9db84d9d20 fix(화면): 패널 높이·접힘이 탭을 새로 열 때마다 초기화되던 것
화면 취향까지 sessionStorage 에 있어 **탭을 새로 열거나 브라우저를 껐다 켜면** 패널 높이·
접힘·유토곡선 열림이 매번 초기화됐음. 계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제
불편은 훨씬 잦았음(사용자 승인 뒤 조사에서 드러남).

- 저장소를 통(bucket)으로 가름 — **`pref` 만 localStorage**, 초안·결과는 세션 그대로.
  설계 데이터가 아니므로 CLAUDE.md 5장 「캐시=세션」에 걸리지 않음.
- 키만 들고 저장소를 직접 만지던 화면 공용 부품 셋(`ui_template_resizer`·`_overlay`·
  `_overlay_drag`)이 같은 판단(`storageOf`)을 쓰게 함 — 안 그러면 높이 값만 세션에 남아
  절반만 옮겨졌음(실측: 접힘 4개는 옮겨지고 높이 3개는 안 옮겨짐).
- 첫 로드 때 세션에 남은 옛 취향을 한 번 쓸어 옮김 — 이 변경으로 쓰던 크기가 초기화되지 않게.

실측(공용 브라우저) — 옮긴 뒤 취향 8개 전부 localStorage, 세션 0개, 높이 보존
(종단 438px · 표 204px). **새 탭에서도 8개 그대로**(같은 값). 검증 탭은 닫음.

시험 `tmp/tests/test_prefs_persist_across_tabs.py` 4건 — 취향만 갈리는지 · 초안·결과는
세션인지 · 공용 부품이 같은 판단을 쓰는지 · 옛 값을 한 번 옮기는지.

이 조각은 DB 없이 되는 몫임. 계정 저장(`018_user_ui_prefs`)은 다음 단계.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:51:58 +09:00

125 lines
5.2 KiB
TypeScript

import "./ui_template_resizer.css";
import { storageOf } from "../A00_Common/b_page_state";
/* =============================================================================
* ui_template_resizer.ts
* 공통 스플리터 — 패널 경계를 끌어 크기를 조절한다.
*
* 조절값은 등록표가 정한 저장소에 남긴다(취향은 브라우저, 나머지는 세션 — 2026-09-07). 같은 브라우저 세션에서는 페이지를 오가도
* 유지되고, 브라우저를 다시 열면 기본값으로 돌아간다(2026-08-01 사용자 지시).
* 사용자별 영구 저장은 나중에 따로 검토한다.
* ========================================================================== */
export interface PanelResizerOptions {
/** vertical=위아래로 끌어 높이 조절, horizontal=좌우로 끌어 폭 조절 */
axis: "vertical" | "horizontal";
/** 크기를 적용할 패널 */
target: HTMLElement;
/**
* 크기를 담을 CSS 변수 이름(예: `--b05-profile-height`).
* 인라인 width/height로 박으면 `.is-collapsed { height: 0 }` 같은 접기 규칙을 이겨 버려
* 패널이 접히지 않는다. 변수로 넘기면 접기 규칙이 그대로 이긴다.
*/
cssVar: string;
/** 손잡이가 패널의 위/왼쪽에 있으면 -1 — 끌어올리거나 왼쪽으로 끌면 커진다. */
direction: 1 | -1;
/** 최소 크기(px). 함수면 끌 때마다 다시 계산한다(열린 서브패널 최소높이 합 같은 동적 하한). */
min: number | (() => number);
/** 최대 크기(px). 함수면 끌 때마다 다시 계산한다(부모의 70% 같은 상대 상한). */
max: number | (() => number);
/** 세션 저장 키. 생략하면 저장하지 않는다. */
storageKey?: string;
/** 크기가 바뀔 때마다 호출한다(차트 재계산 등). */
onResize?: (size: number) => void;
}
export interface PanelResizer {
/** 패널 경계에 붙일 손잡이. 패널이 `position: relative`여야 제자리에 앉는다. */
root: HTMLElement;
/** 저장된 크기를 다시 적용한다(재렌더 후 복원용). */
restore: () => void;
/** 기본 크기로 되돌린다(저장값도 지운다). */
reset: () => void;
dispose: () => void;
}
export function createPanelResizer(options: PanelResizerOptions): PanelResizer {
const { axis, target, cssVar, direction, storageKey, onResize } = options;
const root = document.createElement("div");
root.className = `ui-resizer ui-resizer--${axis}`;
root.setAttribute("role", "separator");
root.setAttribute("aria-orientation", axis === "vertical" ? "horizontal" : "vertical");
const minOf = (): number => (typeof options.min === "function" ? options.min() : options.min);
const maxOf = (): number => (typeof options.max === "function" ? options.max() : options.max);
const clamp = (size: number): number => {
const min = minOf();
const max = Math.max(min, maxOf());
return Math.min(max, Math.max(min, size));
};
function apply(size: number, persist: boolean): void {
const next = clamp(size);
target.style.setProperty(cssVar, `${Math.round(next)}px`);
if (persist && storageKey) storageOf(storageKey).setItem(storageKey, String(Math.round(next)));
onResize?.(next);
}
function restore(): void {
if (!storageKey) return;
const saved = Number(storageOf(storageKey).getItem(storageKey));
// 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다.
if (Number.isFinite(saved) && saved > 0) apply(saved, false);
}
function reset(): void {
target.style.removeProperty(cssVar);
if (storageKey) storageOf(storageKey).removeItem(storageKey);
onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth);
}
let startPosition = 0;
let startSize = 0;
root.addEventListener("pointerdown", (event) => {
// 왼쪽 버튼만 — 가운데·오른쪽 버튼은 브라우저 기본 동작에 맡긴다.
if (event.button !== 0) return;
event.preventDefault();
startPosition = axis === "vertical" ? event.clientY : event.clientX;
startSize = axis === "vertical" ? target.clientHeight : target.clientWidth;
root.classList.add("is-dragging");
// 끄는 동안 크기 transition이 걸려 있으면 손이 끄는 만큼 늦게 따라온다.
target.classList.add("is-resizing");
root.setPointerCapture(event.pointerId);
});
root.addEventListener("pointermove", (event) => {
if (!root.hasPointerCapture(event.pointerId)) return;
const current = axis === "vertical" ? event.clientY : event.clientX;
apply(startSize + (current - startPosition) * direction, true);
});
const stop = (event: PointerEvent): void => {
if (!root.hasPointerCapture(event.pointerId)) return;
root.releasePointerCapture(event.pointerId);
root.classList.remove("is-dragging");
target.classList.remove("is-resizing");
};
root.addEventListener("pointerup", stop);
root.addEventListener("pointercancel", stop);
// 더블클릭하면 기본 크기로 되돌린다(잘못 끌었을 때 되돌리는 가장 빠른 길).
root.addEventListener("dblclick", reset);
restore();
return {
root,
restore,
reset,
dispose() {
root.remove();
},
};
}