Files
Aislo/ui_template/ui_template_side_panels.ts
T
eomsangdonandClaude Opus 5.5 712d3f19d5 fix(ui): 49 오른쪽 접는 패널 = 좌측 패널 손잡이 양식 · 패널 몸만 따로 스크롤 · 원문 보기 한 벌
- 49-1 공용 ui_template_side_panels — 세로 글 띠 · 접기 ▸ 걷어내고 공용 createWorkflowPanelHandle(side · right · 이름표) 손잡이를 패널 왼쪽 가장자리에 붙임 · 패널마다 손잡이 하나 · 하나만 열림 · 폭 공유 · 모두 접힘(폭 0 · 손잡이만 남음) — M01 로직 상세 · M02 일위대가 상세 · 면적산출 상세 모두 따라감
- 49-3 패널 묶음을 보이는 칸 안에 붙임(followView · 위 칸 overflow 가 CSS sticky 를 막아 JS) — 높이 = 보이는 칸 · 머리띠 아래 멈춤 · 몸만 스크롤
- 49-2 원문 md 읽기 — 빈 줄 · 주석이 붙은 표 둘을 가름(한 표로 합쳐지던 깨짐) · 칸 안 <br> = 줄바꿈
- 49-4 원문 칸 80vh 제한 걷어냄 — 표 높이 = 내용 · 가로 넓은 표만 가로 스크롤
- 49-5 원문 꼴 한 벌 — M01 둘레 글자 크기(14) 안 따르고 본문 크기(M02 와 같음)
- 확인 시나리오 ui_checks/49_오른쪽패널_양식.js · 시험 test_m01_logic_source 표 가름 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 16:16:59 +09:00

211 lines
8.1 KiB
TypeScript

/* =============================================================================
* ui_template_side_panels.ts
* 본문 오른쪽 접는 패널 여럿 — 나란히 · 하나만 열림 · 모두 접힘 됨 · 폭 공유
* 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
* 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음
* 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView)
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
* 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
* ========================================================================== */
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { el } from "./ui_template_elements";
import { currentLanguageIndex } from "./ui_template_locale";
import { createWorkflowPanelHandle } from "./ui_template_overlay";
import "./ui_template_side_panels.css";
const TEXT = {
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
} as const;
const ts = (key: keyof typeof TEXT, v: string): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
export interface SidePanelDef {
id: string;
title: string;
body: HTMLElement;
}
export interface SidePanelsOpts {
panels: SidePanelDef[];
/** 취향 키 이름 — `aislo:pref:` 뒤에 붙음 */
prefKey: string;
/** 기억이 없을 때 열 패널(null = 모두 접힘) · 기본 첫 패널 */
defaultOpen?: string | null;
/** 기억이 없을 때 폭(px) · 기본 420 */
defaultWidth?: number;
/** 가장 좁은 폭(px) · 기본 280 · 가장 넓게 = 화면의 85% */
minWidth?: number;
/** 열린 패널이 바뀜(누름 · open 모두) */
onChange?(open: string | null): void;
/** 폭이 바뀜(끌기 · 화살표) */
onResize?(px: number): void;
}
export interface SidePanels {
root: HTMLElement;
/** 열 패널(null = 모두 접음) — 기억함 */
open(id: string | null): void;
current(): string | null;
width(): number;
}
interface Pref {
open?: string | null;
width?: number;
}
/** 끌어 정한 폭 — 가장 작게 min · 가장 크게 화면의 85% */
export function clampSideWidth(px: number, min: number, viewport: number): number {
return Math.round(Math.min(Math.max(min, viewport * 0.85), Math.max(min, px)));
}
export function createSidePanels(opts: SidePanelsOpts): SidePanels {
const key = `aislo:pref:${opts.prefKey}`;
const min = opts.minWidth ?? 280;
const ids = opts.panels.map((p) => p.id);
let pref: Pref = {};
try {
pref = JSON.parse(readByKey(key) ?? "{}") as Pref;
} catch {
/* 깨진 기억 — 기본값 */
}
let now: string | null =
pref.open === null || ids.includes(pref.open ?? "")
? (pref.open ?? null)
: opts.defaultOpen === undefined
? (ids[0] ?? null)
: opts.defaultOpen;
let width = clampSideWidth(pref.width ?? opts.defaultWidth ?? 420, min, window.innerWidth);
const save = (): void => writeByKey(key, JSON.stringify({ open: now, width }));
const root = el("div", { className: "ui-side-panels" });
root.style.setProperty("--ui-side-panels-width", `${width}px`);
const setWidth = (px: number, keep: boolean): void => {
width = clampSideWidth(px, min, window.innerWidth);
root.style.setProperty("--ui-side-panels-width", `${width}px`);
opts.onResize?.(width);
if (keep) save();
};
const handles = el("div", { className: "ui-side-panels__handles" });
root.append(handles);
const views = opts.panels.map((p) => {
const bodyId = `ui-side-panel-${opts.prefKey}-${p.id}`;
const handle = createWorkflowPanelHandle("side", "right", p.title);
handle.root.setAttribute("aria-controls", bodyId);
handle.root.dataset.panel = p.id;
handle.root.addEventListener("click", () => open(now === p.id ? null : p.id));
handles.append(handle.root);
const grip = el("div", {
className: "ui-side-panels__grip",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": ts("Grip", ""),
tabindex: "0",
},
});
const box = el("section", {
className: "ui-side-panels__panel",
attrs: { "data-panel": p.id, "aria-label": p.title },
children: [
grip,
el("header", {
className: "ui-side-panels__head",
children: [el("strong", { className: "ui-side-panels__title", text: p.title })],
}),
el("div", { className: "ui-side-panels__body", attrs: { id: bodyId }, children: [p.body] }),
],
});
grip.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const start = box.getBoundingClientRect().width;
const x0 = ev.clientX;
root.classList.add("is-resizing");
const move = (m: PointerEvent): void => setWidth(start + x0 - m.clientX, false);
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
root.classList.remove("is-resizing");
setWidth(start + x0 - u.clientX, true);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
grip.addEventListener("keydown", (ev) => {
const step = ev.shiftKey ? 80 : 20;
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
if (!d) return;
ev.preventDefault();
setWidth(width + d, true);
});
root.append(box);
return { id: p.id, handle, box };
});
const draw = (): void => {
for (const v of views) {
const on = v.id === now;
v.handle.setOpen(on);
v.box.hidden = !on;
}
root.classList.toggle("is-collapsed", now === null);
};
function open(id: string | null): void {
const next = id !== null && ids.includes(id) ? id : null;
const was = now;
now = next;
draw();
save();
if (next !== was) opts.onChange?.(next);
}
draw();
followView(root);
return { root, open, current: () => now, width: () => width };
}
/** 가장 가까운 스크롤 칸(없으면 창) */
function scroller(node: HTMLElement): HTMLElement | null {
for (let n = node.parentElement; n; n = n.parentElement) {
const y = getComputedStyle(n).overflowY;
if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n;
}
return null;
}
/**
* 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 칸 · 본문을 굴려도 그 자리(몸만 따로 스크롤)
* CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼
*/
function followView(node: HTMLElement): void {
const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침
const place = (): void => {
const root = ref.deref();
if (!root) return stop();
if (!root.isConnected) return;
const box = scroller(root)?.getBoundingClientRect();
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
const top = Math.max(head, box?.top ?? 0) + 8;
const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight) - 8;
const row = root.parentElement?.getBoundingClientRect();
if (!row) return;
const height = Math.max(240, bottom - top);
root.style.height = `${height}px`;
const shift = Math.min(Math.max(0, top - row.top), Math.max(0, row.height - height));
root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : "";
};
const stop = (): void => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
setTimeout(place); // 붙은 뒤 한 번
}