/* ============================================================================= * M01_MasterData_UI_LogicLab_Side.ts * 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절) * 공용 접기 카드(`ui-collapsible` · 제목 행 누름 · 캐럿) 두 장 — attachCollapsible 토글 위에 하나만 열림 규칙 * 둘 다 접기 됨 · 접힌 카드 = 제목 행만 * 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { el } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow"; import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css"; import "./M01_MasterData_UI_LogicLab_Side.css"; const TEXT = { Flow: ["흐름도", "Flow"], Source: ["원문", "Source"], Loading: ["원문 찾는 중", "Looking up the source"], None: ["원문 연결 없음", "No linked source"], From: ["출처", "Source"], NoSource: ["출처 없음", "No source"], Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"], } as const; const ts = (key: keyof typeof TEXT, v = ""): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); /** 둘 다 접힘 — 제목 행 둘이 들어가는 칸 너비 */ const FOLDED = 140; export type PanelPick = "flow" | "source" | "none"; interface Block { h?: number; text?: string; p?: string; table?: string[][]; } interface Found { number: string; matched: string; file: string; blocks: Block[]; } export interface LogicSide { /** 흐름도 패널에 넘길 칸 — right = 흐름도 자리 · setRight = 흐름도 패널이 알린 너비 */ flowSplit: SplitHandle; flowFolded(): boolean; pick(next: PanelPick): void; /** 흐름도 패널이 뜨면 접기 함수 · 내리면 null */ bindFlow(fold: ((on: boolean) => void) | null): void; /** 연 로직의 출처 글 — 원문 패널이 열려 있으면 찾음 */ showSource(src: string): void; } const readPick = (): PanelPick => { const got = readStateRaw("m01-logic-panel"); return got === "source" || got === "none" ? got : "flow"; }; function blockNode(b: Block): HTMLElement { if (b.table) return el("div", { className: "m01-logic__scroll", children: [ el("table", { className: "m01-logic__grid m01lab-src__table", children: [ el("tbody", { children: b.table.map((r) => el("tr", { children: r.map((c) => el("td", { text: c })) }), ), }), ], }), ], }); if (b.h) return el("h4", { className: "m01lab-src__head", text: b.text ?? "" }); return el("p", { className: "m01lab-src__p", text: b.p ?? "" }); } export function buildLogicSide(split: SplitHandle): LogicSide { const flowSlot = el("div", { className: "m01lab-side__slot" }); const body = el("div", { className: "m01lab-src__body" }); const title = el("strong", { className: "m02-dfp__title" }); const card = (label: string, inner: HTMLElement): HTMLElement => el("section", { className: "m01lab-side__card ui-collapsible ui-sidebar-section ui-sidebar-card", children: [el("h3", { className: "ui-collapsible__title", text: label }), inner], }); const flowCard = card(ts("Flow"), flowSlot); const sourceCard = card( ts("Source"), el("div", { className: "m01lab-src", children: [title, body] }), ); const side = el("div", { className: "m01lab-side ui-sidebar-panel", children: [flowCard, sourceCard], }); attachCollapsible(side); // 공용 토글 뒤 — 연 카드만 남기고 다른 카드는 접음(접었으면 둘 다 닫힘) side.addEventListener("click", (ev) => { const at = (ev.target as HTMLElement).closest(".ui-collapsible__title")?.parentElement; if (at !== flowCard && at !== sourceCard) return; pick(at.classList.contains("is-collapsed") ? "none" : at === flowCard ? "flow" : "source"); }); split.right.replaceChildren(side); let now = readPick(); let flowPx: number | null = null; let flowFold: ((on: boolean) => void) | null = null; let want = ""; let shown: string | null = null; /** 카드 안팎 여백(좌우 안쪽 + 테두리) — 흐름도 패널 너비에 더함 */ const pad = (): number => { const s = getComputedStyle(flowCard); return ["paddingLeft", "paddingRight", "borderLeftWidth", "borderRightWidth"].reduce( (n, k) => n + (parseFloat(s[k as "paddingLeft"]) || 0), 0, ); }; /** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 카드 여백 · 둘 다 접힘 = 제목 행 */ const width = (): void => split.setRight( now === "source" || (now === "flow" && flowPx === null) ? null : now === "flow" ? (flowPx ?? 0) + pad() : FOLDED, ); const load = (): void => { if (now !== "source" || shown === want) return; const src = (shown = want); title.textContent = src || ts("NoSource"); body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") })); const none = (): void => body.replaceChildren( el("p", { className: "m01lab-src__none", text: ts("None") }), el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }), ); if (!src.trim()) return none(); void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, { credentials: "include", }) .then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] })) .catch(() => ({ found: [] as Found[] })) .then(({ found }) => { if (shown !== src) return; if (!found.length) return none(); body.replaceChildren( ...found.flatMap((f) => [ el("p", { className: "m01-logic__muted m01lab-src__file", text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`, }), ...f.blocks.map(blockNode), ]), ); }); }; const pick = (next: PanelPick): void => { if (next !== now) writeStateRaw("m01-logic-panel", next); now = next; flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠 flowCard.classList.toggle("is-collapsed", now !== "flow"); sourceCard.classList.toggle("is-collapsed", now !== "source"); width(); load(); }; pick(now); return { flowSplit: { root: split.root, right: flowSlot, setRight: (px) => { flowPx = px; width(); }, }, flowFolded: () => now !== "flow", pick, bindFlow: (f) => { flowFold = f; }, showSource: (src) => { want = src; load(); }, }; }