From 712aac8e39051b7458b0b293487b256fb6baaa79 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:14:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0=20=E2=80=94=20=EC=97=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B2=AC=EB=B3=B8=20=EB=B3=B5=EC=82=AC=20=C2=B7=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EA=B0=92=20=C2=B7=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=C2=B7=20=ED=95=A8=EC=88=98/=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=ED=8C=90=20=C2=B7=20=EC=9B=90=EA=B0=80=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5(=EC=84=9C=EB=B2=84=20=EB=8C=80=EC=A1=B0)=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/CostFlow/M02_CF_Entry.css | 15 +- M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 434 +++++++++++++++---- 2 files changed, 360 insertions(+), 89 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css index 98c3faa23..487d834fa 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css @@ -115,10 +115,6 @@ padding: var(--spacing-12); } -.m02-cf__diagram .m02-cf__part-body { - min-height: 60vh; -} - .m02-cf__lower { display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); @@ -189,3 +185,14 @@ font-size: var(--text-body-sm); white-space: nowrap; } + +.m02-cf__note { + margin: 0 0 var(--spacing-12); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m02-cf__note:empty { + display: none; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts index 84e7a2f91..f1e180935 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -1,9 +1,10 @@ /* ============================================================================= * M02_CF_Entry.ts - * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀 + * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게. - * 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음. + * 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary + * → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S. * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). * ========================================================================== */ @@ -17,21 +18,47 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { CfListItem } from "./M02_CF_Api"; -import type { CfLogic } from "./M02_CF_Model"; +import type { CfListItem, CfQuery } from "./M02_CF_Api"; +import type { CfEvalResult, CfValues } from "./M02_CF_Eval"; +import type { CfKind, CfLogic, CfPort } from "./M02_CF_Model"; import "./M02_CF_Entry.css"; +/** CostFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ +const loadMods = () => + Promise.all([ + import("./M02_CF_Model"), + import("./M02_CF_Eval"), + import("./M02_CF_Api"), + import("./M02_CF_Diagram"), + import("./M02_CF_FuncPanel"), + import("./M02_CF_OutputPanel"), + import("./M02_CF_Summary"), + import("./M02_CF_Sample"), + ]).then(([model, ev, api, diagram, func, output, summary, sample]) => ({ + model, + ev, + api, + diagram, + func, + output, + summary, + sample, + })); +type Mods = Awaited>; + const TEXT = { Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], Title: ["단가산출 다이어그램", "Cost diagram"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], + OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], Start_New: ["새로 만들기", "New"], Start_Mine: ["내 로직", "My logics"], Start_MineNone: ["아직 없음", "None yet"], - Start_Samples: ["견본", "Samples"], + Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], Name: ["로직 이름", "Logic name"], NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], Unit: ["결과 단위", "Result unit"], @@ -41,11 +68,24 @@ const TEXT = { Unsaved: ["저장 안 함", "Not saved"], Diagram: ["다이어그램", "Diagram"], Box: ["고른 상자", "Chosen box"], - Panel_Input: ["입력", "Input"], - Panel_Function: ["함수", "Function"], - Panel_Output: ["출력", "Output"], + PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], + InputSoon: ["입력 상자 판은 곧 붙음", "The input box panel is coming"], Summary: ["원가", "Cost"], - NotYet: ["준비 중", "Coming soon"], + NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], + SaveAnyway: [ + "고칠 곳이 있음 — {v} · 그래도 저장할까요?", + "There are issues — {v} · Save anyway?", + ], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], + Differs: [ + "저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름", + "Recalculated on save: {s} — differs from the screen value {c}", + ], + Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Deleted: ["지웠음", "Deleted"], + Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -55,17 +95,14 @@ const TEXT = { type TextKey = keyof typeof TEXT; -const te = (key: TextKey): string => - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; +const te = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text }); -/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */ -const blankLogic = (name: string, unit: string): Promise => - import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null); -const loadList = (): Promise => - import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []); - /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ async function unitChoices(): Promise { const count = new Map(); @@ -83,6 +120,44 @@ async function unitChoices(): Promise { return units.length ? units : ["원/㎡"]; } +/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ +function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] { + return lg.boxes.flatMap((b) => { + const inp = b.input; + if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; + const group = inp.group; + const els = inp.elements + .filter((e) => e.ref && !e.ref.startsWith("UE-")) + .map((e) => ({ + q: { id: `${b.id}\u0001${e.name}`, group, ref: e.ref, unit: e.unit }, + box: b.id, + port: e.name, + })); + const table = inp.table + ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] + : []; + return [...els, ...table]; + }); +} + +/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ +async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> { + const asks = queriesOf(lg); + if (!asks.length) return { values: {}, errors: [] }; + const answers = await M.api.cfValues( + asks.map((a) => a.q), + lg.userElements as never, + ); + const values: CfValues = {}; + const errors: string[] = []; + for (const a of asks) { + const got = answers.find((x) => x.id === a.q.id); + if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); + else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; + } + return { values, errors }; +} + /** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ export function createCostFlowButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow }); @@ -90,8 +165,9 @@ export function createCostFlowButton(): HTMLButtonElement { function openCostFlow(): void { /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean } | null = null; + let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let units: string[] | null = null; + const mods = loadMods(); const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); @@ -103,7 +179,7 @@ function openCostFlow(): void { variant: "ghost", onClick: () => void (async () => { - if (await mayLeave()) shut(); + if (await mayLeave()) paper ? void showStart() : shut(); })(), }); const page = el("div", { @@ -128,42 +204,52 @@ function openCostFlow(): void { document.body.style.overflow = prevOverflow; }; page.addEventListener("keydown", (ev) => { - if (ev.key !== "Escape" || ev.defaultPrevented) return; - void (async () => { - if (await mayLeave()) shut(); - })(); + if (ev.defaultPrevented) return; + if (ev.key === "Escape") { + void (async () => { + if (await mayLeave()) shut(); + })(); + return; + } + if (!paper || !(ev.ctrlKey || ev.metaKey)) return; + const key = ev.key.toLowerCase(); + if (key === "z" && !ev.shiftKey) { + ev.preventDefault(); + paper.undo(); + } else if (key === "s") { + ev.preventDefault(); + void paper.save(); + } }); + const run = (job: (M: Mods) => Promise): void => { + body.replaceChildren(muted(te("Loading"))); + void mods.then(job).catch((e: unknown) => { + showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error"); + void showStart(); + }); + }; + /* ---------- 시작 화면 ---------- */ - const listRow = (item: CfListItem): HTMLElement => { + const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { const row = el("button", { className: "m02-cf__item", - attrs: { type: "button", title: item.saved }, + attrs: { type: "button", title }, children: [ - el("span", { className: "m02-cf__item-name", text: item.name }), - el("span", { className: "m02-cf__item-unit", text: item.resultUnit }), + el("span", { className: "m02-cf__item-name", text: name }), + el("span", { className: "m02-cf__item-unit", text: unit }), ], }); - row.addEventListener("click", () => showToast(te("NotYet"), "info")); + row.addEventListener("click", onClick); return row; }; - const showStart = (): void => { + const showStart = async (): Promise => { paper = null; strip.replaceChildren(); const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); - const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); - void loadList().then((all) => { - const mine = all.filter((x) => !x.sample); - const samples = all.filter((x) => x.sample); - mineHost.replaceChildren( - ...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]), - ); - sampleHost.replaceChildren( - ...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]), - ); - }); + const sampleHost = el("div", { className: "m02-cf__list" }); body.replaceChildren( el("div", { className: "m02-cf__start", @@ -171,7 +257,9 @@ function openCostFlow(): void { el("section", { className: "m02-cf__start-col", children: [ - bigButton(te("Start_New"), () => void openNew()), + bigButton(te("Start_New"), () => + run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), + ), el("h3", { text: te("Start_Mine") }), mineHost, ], @@ -183,41 +271,231 @@ function openCostFlow(): void { ], }), ); + const M = await mods; + sampleHost.replaceChildren( + ...M.sample.sampleList().map((s) => + listRow( + s.name, + s.resultUnit, + () => + run(async (M2) => { + const lg = M2.sample.sampleLoad(s.id); + if (lg) await showPaper(M2, lg, false); + }), + s.note, + ), + ), + ); + try { + const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); + mineHost.replaceChildren( + ...(mine.length + ? mine.map((x) => + listRow( + x.name, + x.resultUnit, + () => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)), + x.saved, + ), + ) + : [muted(te("Start_MineNone"))]), + ); + } catch (e) { + mineHost.replaceChildren(muted(M.api.cfPlain(e))); + } }; + const units_ = async (): Promise => (units ??= await unitChoices()); /* ---------- 고치는 화면 ---------- */ - const openNew = async (): Promise => { - units ??= await unitChoices(); - const lg = await blankLogic("", units[0]); - showPaper(lg, units); - }; + const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise => { + const choices = await units_(); + let lg = first; + let savedLg: CfLogic | null = saved ? first : null; + let stack: CfLogic[] = []; + let selected: string | null = null; + let values: CfValues = {}; + let valueSig = ""; + let notes: string[] = []; + let result: CfEvalResult; + let busy = false; - const showPaper = (lg: CfLogic | null, choices: string[]): void => { - let dirty = false; - paper = { dirty: () => dirty }; const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = true; - const touched = (): void => { - dirty = true; - unsaved.hidden = false; + const note = el("p", { className: "m02-cf__note", attrs: { role: "status" } }); + const diagramHost = el("div"); + const pickHint = muted(te("PickBox")); + // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) + const funcHost = el("div"); + const outHost = el("div"); + const inHost = el("div", { children: [muted(te("InputSoon"))] }); + const summaryHost = el("div"); + + const evalNow = (): CfEvalResult => { + try { + return M.ev.evalLogic(lg, values); + } catch (e) { + const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; + } }; + + const edit = (next: CfLogic): void => { + if (next === lg) return; + stack = [...stack, lg]; + lg = next; + redraw(); + }; + /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */ + const editFromDiagram = (next: CfLogic): void => { + edit(next); + setTimeout(redraw); + }; + + /** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ + const refreshValues = (): void => { + const sig = JSON.stringify(queriesOf(lg).map((a) => a.q)); + if (sig === valueSig) return; + valueSig = sig; + void fetchValues(M, lg).then( + (got) => { + if (sig !== valueSig) return; + values = got.values; + notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : []; + redraw(); + }, + (e: unknown) => { + notes = [te("Values", { v: M.api.cfPlain(e) })]; + redraw(); + }, + ); + }; + const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), - value: lg?.name ?? "", - onInput: touched, + value: lg.name, + onInput: (v) => edit({ ...lg, name: v }), }); name.root.classList.add("m02-cf__name"); + const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices]; const unit = createSelectField({ label: te("Unit"), - options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: lg?.resultUnit ?? choices[0], - onChange: touched, + options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: lg.resultUnit, + onChange: (v) => edit({ ...lg, resultUnit: v }), + }); + + const diagramOn = { + select: (id: string | null): void => { + selected = id; + drawPanel(); + }, + move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), + link: (from: CfPort, to: CfPort): string | null => { + const got = M.model.link(lg, from, to); + if (got.why) return got.why; + editFromDiagram(got.lg); + return null; + }, + unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)), + add: (kind: CfKind, x: number, y: number): void => { + const got = M.model.addBox(lg, kind, x, y); + selected = got.box.id; + editFromDiagram(got.lg); + }, + remove: (id: string): void => { + if (selected === id) selected = null; + editFromDiagram(M.model.removeBox(lg, id)); + }, + }; + + const drawPanel = (): void => { + const box = lg.boxes.find((b) => b.id === selected); + pickHint.hidden = !!box; + funcHost.hidden = box?.kind !== "function"; + outHost.hidden = box?.kind !== "output"; + inHost.hidden = box?.kind !== "input"; + if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "output") + M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); + }; + + const redraw = (): void => { + result = evalNow(); + refreshValues(); + unsaved.hidden = lg === savedLg; + undo.disabled = !stack.length; + deleteButton.hidden = !lg.key; + M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected }); + drawPanel(); + M.summary.renderSummary(summaryHost, lg, result, { change: edit }); + note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n"); + }; + + const save = async (): Promise => { + if (busy) return; + if (!lg.name.trim()) { + note.textContent = te("NoName"); + return void name.input.focus(); + } + let problems: string[] = []; + try { + problems = M.ev.checkLogic(lg, values); + } catch { + problems = []; + } + const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); + if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return; + busy = true; + note.textContent = te("Saving"); + const screen = result.cost.계; + try { + const got = await M.api.cfSave(lg); + lg = got.logic; + savedLg = lg; + stack = []; + redraw(); + const server = got.values.cost.계; + if (Math.abs(server - screen) > 0.5) + notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; + note.textContent = [...notes, ...result.errors].join("\n"); + showToast(te("Saved"), "success"); + } catch (e) { + note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) }); + } finally { + busy = false; + } + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back) return; + stack = stack.slice(0, -1); + lg = back; + name.input.value = lg.name; + unit.select.value = lg.resultUnit; + redraw(); + }; + + const doDelete = async (): Promise => { + if (!lg.key || busy) return; + if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return; + try { + await M.api.cfDelete(lg.key); + savedLg = lg; + showToast(te("Deleted"), "success"); + void showStart(); + } catch (e) { + note.textContent = M.api.cfPlain(e); + } + }; + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); + const deleteButton = createButton({ + label: te("Delete"), + variant: "danger", + onClick: () => void doDelete(), }); - const notYet = (): void => showToast(te("NotYet"), "info"); - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; strip.replaceChildren( name.root, unit.root, @@ -226,39 +504,33 @@ function openCostFlow(): void { className: "m02-cf__actions", children: [ undo, - createButton({ label: te("Save"), onClick: notYet }), - createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), + createButton({ label: te("Save"), onClick: () => void save() }), + deleteButton, ], }), ); body.replaceChildren( + note, el("div", { className: "m02-cf__layout", children: [ - part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]), + part(te("Diagram"), "m02-cf__diagram", [diagramHost]), el("div", { className: "m02-cf__lower", children: [ - part(te("Box"), "m02-cf__box", [ - el("div", { - className: "m02-cf__box-slots", - children: [ - slot(te("Panel_Input")), - slot(te("Panel_Function")), - slot(te("Panel_Output")), - ], - }), - ]), - part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]), + part(te("Box"), "m02-cf__box", [pickHint, inHost, funcHost, outHost]), + part(te("Summary"), "m02-cf__summary", [summaryHost]), ], }), ], }), ); - name.input.focus(); + paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; + redraw(); + if (!lg.name) name.input.focus(); }; - showStart(); + void showStart(); document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 document.body.append(page); page.tabIndex = -1; @@ -273,14 +545,6 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl }); } -/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */ -function slot(title: string): HTMLElement { - return el("div", { - className: "m02-cf__slot", - children: [el("h4", { text: title }), muted(te("NotYet"))], - }); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m02-cf__big");