/* ============================================================================= * M02_DF_Panel.ts * 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand } * 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) → * 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange. * 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage · * 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로). * 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import type { CfLogic } from "./M02_DF_Model"; import { createPaper } from "./M02_DF_Paper"; import "./M02_DF_Panel.css"; const TEXT = { Big: ["크게 보기", "Full screen"], Fold: ["접기", "Collapse"], Strip: ["흐름도 ▸", "Flow ▸"], StripTitle: ["흐름도 펴기", "Open the flow panel"], Close: ["닫기", "Close"], Detail: ["상세 내용", "Details"], Cost: ["원가 · 검증", "Cost · check"], Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"], } as const; const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; export interface FlowPanelOpts { logic: CfLogic; readOnly?: boolean; title: string; /** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */ onChange(lg: CfLogic): void; /** 다른 로직 입력이 부르는 로직 */ logics?(key: string): Promise; /** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */ onResize?(px: number): void; } export interface FlowPanel { /** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */ set(lg: CfLogic): void; destroy(): void; /** 크게 보기 열기 */ expand(): void; } /* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */ export const PANEL_MIN = 320; export const STRIP_WIDTH = 40; /** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */ export const PANEL_FIT_MIN = 0.6; /** 패널 「한눈에」 단추 — 전체가 보이게(가장 작은 확대) */ export const PANEL_FIT_ALL_MIN = 0.25; const WIDTH_KEY = "m02.dfpanel.width"; const FOLD_KEY = "m02.dfpanel.folded"; /** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */ export function dragWidth(start: number, startX: number, x: number, viewport: number): number { const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85)); return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x)))); } /** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */ export function storedWidth(raw: string | null): number | null { const n = Number(raw); return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null; } const readStore = (key: string): string | null => { try { return localStorage.getItem(key); } catch { return null; } }; const writeStore = (key: string, value: string): void => { try { localStorage.setItem(key, value); } catch { /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */ } }; /* ── 패널 ───────────────────────────────────────────────────────────── */ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel { /** 크게 보기 화면 — 열려 있을 때만 */ let full: HTMLElement | null = null; const paper = createPaper({ logic: opts.logic, readOnly: opts.readOnly, onChange: opts.onChange, logics: opts.logics, // 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 90% fitMin: () => (full ? 0.9 : PANEL_FIT_MIN), fitAllMin: () => (full ? 0.9 : PANEL_FIT_ALL_MIN), }); const { diagram, detail, summary, note, undo } = paper.parts; let width = storedWidth(readStore(WIDTH_KEY)); let folded = readStore(FOLD_KEY) === "1"; const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth)); const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") => createButton({ label, size: "sm", variant, onClick }); /* 펼친 패널 */ const costBox = el("details", { className: "m02-dfp__cost", attrs: { open: "" }, children: [el("summary", { text: tf("Cost") })], }); const tools = el("div", { className: "m02-dfp__tools", children: [undo] }); const open = el("div", { className: "m02-dfp__open", children: [ el("header", { className: "m02-dfp__head", children: [ el("strong", { className: "m02-dfp__title", text: opts.title }), sm(tf("Big"), () => expand()), sm(tf("Fold"), () => setFolded(true)), ], }), tools, note, diagram, detail, costBox, ], }); costBox.append(summary); /* 접힌 띠 */ const strip = el("button", { className: "m02-dfp__strip", text: tf("Strip"), attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") }, }); strip.addEventListener("click", () => setFolded(false)); /* 왼쪽 가장자리 — 끌기 · 화살표 키 */ const grip = el("div", { className: "m02-dfp__grip", attrs: { role: "separator", "aria-orientation": "vertical", "aria-label": tf("Grip"), tabindex: "0", }, }); const setWidth = (px: number, save: boolean): void => { width = px; if (save) writeStore(WIDTH_KEY, String(px)); tell(); }; grip.addEventListener("pointerdown", (ev) => { if (ev.button !== 0) return; ev.preventDefault(); const start = root.getBoundingClientRect().width; const startX = ev.clientX; root.classList.add("is-resizing"); const move = (m: PointerEvent): void => setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false); const up = (u: PointerEvent): void => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); root.classList.remove("is-resizing"); setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true); paper.refit(); }; 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(); const now = root.getBoundingClientRect().width; setWidth(dragWidth(now, 0, -d, window.innerWidth), true); }); const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] }); root.addEventListener("keydown", (ev) => { if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; ev.preventDefault(); paper.undo(); } }); const setFolded = (on: boolean): void => { folded = on; writeStore(FOLD_KEY, on ? "1" : "0"); root.classList.toggle("is-folded", on); open.hidden = on; strip.hidden = !on; grip.hidden = on; tell(); if (!on) paper.refit(); }; /* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */ const expand = (): void => { if (full) return; const close = sm(tf("Close"), () => shrink()); full = el("div", { className: "m02-dfp-full", attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" }, children: [ el("header", { className: "m02-dfp-full__head", children: [el("strong", { text: opts.title }), undo, close], }), note, el("div", { className: "m02-dfp-full__diagram", children: [diagram] }), el("div", { className: "m02-dfp-full__lower", children: [ el("section", { children: [el("h3", { text: tf("Detail") }), detail] }), el("section", { children: [el("h3", { text: tf("Cost") }), summary] }), ], }), ], }); full.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && !ev.defaultPrevented) shrink(); if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; ev.preventDefault(); paper.undo(); } }); document.body.append(full); full.focus(); paper.refit(); }; const shrink = (): void => { if (!full) return; full.remove(); full = null; tools.append(undo); costBox.append(summary); // 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움 open.replaceChildren( open.firstElementChild as HTMLElement, tools, note, diagram, detail, costBox, ); paper.refit(); }; host.replaceChildren(root); setFolded(folded); return { set: (lg) => paper.set(lg), destroy: () => { shrink(); root.remove(); }, expand, }; }