/* ============================================================================= * 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); // 붙은 뒤 한 번 }