- 세로: 머리(제목 · 크게 보기 · 접기) · 실행 취소 · 다이어그램 · 상세 내용 · 원가 · 검증(접기) · 저장 단추 없음 - 왼쪽 가장자리 끌기 · 화살표 키 → onResize(px) · 마지막 너비 localStorage · 접기 = 얇은 띠 「흐름도 ▸」 - 크게 보기 = 같은 판을 화면 전체로 옮겨 끼움 · 닫으면 패널로(고친 것 그대로) · Esc · Ctrl+Z - 좁은 패널은 저절로 맞춤 바닥 0.5(한눈에) · 크게 보기는 0.9 — 다이어그램 opts.fitMin - 시험 test_m02_df_panel.py 2 통과 · typecheck · DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
263 lines
9.5 KiB
TypeScript
263 lines
9.5 KiB
TypeScript
/* =============================================================================
|
|
* 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<CfLogic | null>;
|
|
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
|
|
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.5;
|
|
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 {
|
|
const paper = createPaper({
|
|
logic: opts.logic,
|
|
readOnly: opts.readOnly,
|
|
onChange: opts.onChange,
|
|
logics: opts.logics,
|
|
// 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림
|
|
fitMin: () => (full ? 0.9 : PANEL_FIT_MIN),
|
|
});
|
|
const { diagram, detail, summary, note, undo } = paper.parts;
|
|
let width = storedWidth(readStore(WIDTH_KEY));
|
|
let folded = readStore(FOLD_KEY) === "1";
|
|
let full: HTMLElement | null = null;
|
|
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);
|
|
open.insertBefore(note, diagram);
|
|
open.insertBefore(diagram, detail);
|
|
open.insertBefore(detail, costBox);
|
|
costBox.append(summary);
|
|
paper.refit();
|
|
};
|
|
|
|
host.replaceChildren(root);
|
|
setFolded(folded);
|
|
|
|
return {
|
|
set: (lg) => paper.set(lg),
|
|
destroy: () => {
|
|
shrink();
|
|
root.remove();
|
|
},
|
|
expand,
|
|
};
|
|
}
|