Files
Aislo/A00_Common/formula_doc_screen.ts
T
eomsangdonandClaude Opus 5.5 970fd441ba fix(803-2b): 저장 전 새 로직 기호 식 영역 누름 = 입력 · 편집 줄 너비 전부
- 까닭 — 수식 화면 고침 조건이 「관리자 + 로직 키」 · 신규 로직은 저장 전 키가 없어 영역 누름 처리가 아예 안 붙음(GC000211 같은 저장된 로직은 누름 됨)
- M01 저장 전 새 로직도 고침 · 고친 문서는 캐시 「""」 열쇠 · 미리 셈은 임시 키 · [수식 저장] · [고침 버림]은 키가 생긴 뒤
- 페이지 [저장]으로 새 키가 생기면 고친 문서를 그 키(빈 문서)로 이어 감(이름 · 단위 = 새 로직) · 신규 로직을 다시 열면 옛 고침 걷음
- M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만 그대로
- 편집 중 줄 = 그 줄 너비 전부(빈 줄 입력 칸이 2em 으로 줄던 것 · 「a =」 「3」 꺾임 고침)
- 서버 · 계산기 · 문서 꼴 그대로 · 새 라이브러리 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:27:54 +09:00

668 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* formula_doc_screen.ts
* 단가산출 기초 수식 문서 화면(PLAN 802-3 · 803-2a · 요구 660~667 · 요구서_664 3 장) — 공용(M01 · M02 = 길 앞머리만 바꿈)
* 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · [고침 버림] · [수식 저장](관리자) — 설명 글 없음
* 가운데 = 세 영역 가로 — 기호 식(하나의 수식 영역 · 줄바꿈) | 값 식(같은 높이) | 기호 정의(`formula_doc_defs.ts`)
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 단가산출 상세 탭 부품 · `formula_doc_foot.ts`)
* 기호 식 줄 누름 = 그 자리 편집기(`formula_doc_math.ts`) · 편집 중엔 영역 위 수식 도구 줄 · 빈 문서 = 세 영역 빈 화면
* 빈 문서에서 시작 — 영역을 눌러 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 연결 값은 서버 기록 캐시(`common_util_formula_doc_cache.ts`)
* · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈
* 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀)
* 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」
* M02 사본(`opt.copy` · 802-4b-2) — 길 앞머리 = SK 마다(`/m02/layers/{층}/sangeun/{키}/doc`) · 옵션 = 문서 값(SK 고른 값 · 저장) ·
* [단가 갱신] · [재생성] · 「M01 새 판 있음」(`formula_doc_copy.ts`) · 단가산출 연결 끊기 · 다시 잇기(`formula_doc_sub.ts`) ·
* 저장 몸 {문서, 끊음, 판} · `admin` = 층 쓰기 권한(없으면 읽기만 · 옵션도 잠금)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { editNumber } from "@ui/ui_template_equation_edit";
import {
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import {
DocError,
blankDoc,
rename,
varId,
type FormulaDoc,
type Link,
} from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import {
askJob,
copyBadges,
copyTools,
type CopyJob,
type CopyMark,
type CopyOptions,
type JobReply,
} from "./formula_doc_copy";
import { buildDefs, type Pick } from "./formula_doc_defs";
import { editLit, ensureVar, splitLine, sweep } from "./formula_doc_edit";
import { docFoot } from "./formula_doc_foot";
import { buildLines, offLines } from "./formula_doc_lines";
import { mathToolbar } from "./formula_doc_math";
import { buildOptions } from "./formula_doc_options";
import { reach } from "./formula_doc_render";
import { createRows } from "./formula_doc_rows";
import { subBlock } from "./formula_doc_sub";
import { ft } from "./formula_doc_text";
export { splitLine };
import "./formula_doc.css";
export interface DocScreenOptions {
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links · GET /table */
base: string;
admin: boolean;
/** sessionStorage 열쇠 */
cache: string;
/** M02 사본(SK) — 길 앞머리 · 판 · 끊음 · 할일 */
copy?: CopyOptions;
}
type Docs = Record<string, FormulaDoc>;
interface Loaded {
문서: FormulaDoc;
셈: DocAnswer;
연결: DocRecord;
새?: boolean;
부름?: Docs;
끊음?: Docs;
사본?: CopyMark;
판?: string;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
export function createDocScreen(opt: DocScreenOptions) {
const head = el("div", { className: "fdoc__head" });
const grid = el("div", { className: "fdoc__grid" });
const side = el("div", { className: "fdoc__side" });
const foot = el("div", { className: "fdoc__footslot" });
const root = el("div", {
className: "fdoc ui-eq",
attrs: { "data-formula-doc": "" },
children: [head, el("div", { className: "fdoc__areas", children: [grid, side] }), foot],
});
let key = "";
let saved = "";
let doc: FormulaDoc | null = null;
let answer: DocAnswer | null = null;
let cache = createDocCache();
let given: Record<string, unknown> = {};
let openDef: string | null = null;
let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드)
let seq = 0;
let asked = "";
let ctl: AbortController | null = null;
const tables = new Map<string, Promise<DocTable | null>>();
const rows = createRows({
doc: () => doc,
change: (fn) => change(fn),
settle: () => {
keep();
recalc();
},
draw: () => draw(),
root,
});
const cp = opt.copy;
let cut: Docs = {}; // 사본 — 끊은 단가산출 문서(이 사본 안 · 고칠 수 있음)
let savedCut = "{}";
let calls: Docs = {}; // 사본 — 부름 문서(다시 잇기 = 이 문서로)
let mark: CopyMark | undefined;
let version = "";
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) {
const why = data.detail;
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
status: r.status,
});
}
return data;
};
// ── 캐시(sessionStorage) ──
const drafts = (): Record<string, FormulaDoc> => {
try {
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
} catch {
return {};
}
};
const dirty = (): boolean =>
!!doc && (JSON.stringify(doc) !== saved || JSON.stringify(cut) !== savedCut);
const cutKey = (): string => `${key}#끊음`;
const keep = (): void => {
const all = drafts();
if (dirty()) all[key] = doc!;
else delete all[key];
if (cp && dirty()) all[cutKey()] = cut as unknown as FormulaDoc;
else delete all[cutKey()];
try {
sessionStorage.setItem(opt.cache, JSON.stringify(all));
} catch {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
};
// ── 셈 ──
const usable = (): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const v of doc?.변수 ?? []) {
const k = v.열쇠;
if (!k || !(k in given)) continue;
const kind = v.연결?.갈래;
if (kind === "입력") out[k] = given[k];
if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k])))
out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k]));
}
return out;
};
const recalc = (): void => {
if (!doc) return;
answer = calcDoc(doc, cache.links, usable());
if (cache.takeMissing()) ask();
};
/** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함(같은 문서 · 입력은 한 번만) */
const ask = (): void => {
const snap = JSON.stringify([doc, usable()]);
if (snap === asked) return;
asked = snap;
ctl?.abort();
ctl = new AbortController();
const mine = ++seq;
void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>(
"/preview",
cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() },
ctl.signal,
)
.then((got) => {
if (mine !== seq) return;
cache.merge(got.연결);
calls = { ...calls, ...(got.부름 ?? {}) };
cache.merge({ 문서: cut });
recalc();
draw();
})
.catch(() => undefined);
};
const change = (fn: (d: FormulaDoc) => void): void => {
if (!doc) return;
fn(doc);
keep();
recalc();
draw();
};
const onPickVar = (id: string, num?: HTMLElement): void => {
const v = doc?.변수?.find((x) => varId(x) === id);
if (opt.admin && num && v?.연결?.갈래 === "숫자") {
editNumber(num, (t) => {
const n = Number(t.replace(/,/g, ""));
change((d) => (ensureVar(d, id).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t }));
});
return;
}
openDef = id;
draw();
grid.querySelector(`[data-def="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
};
const tableOf = (k: string): Promise<DocTable | null> => {
if (!tables.has(k))
tables.set(
k,
call<DocTable & { 끊김?: string }>(`/table?key=${encodeURIComponent(k)}`)
.then((t) => {
if (t.끊김) return null;
cache.putTable(k, t);
return t;
})
.catch(() => null),
);
return tables.get(k)!;
};
const subDoc = (k: string): FormulaDoc | null => {
try {
return cache.links.doc(k) ?? null;
} catch {
cache.takeMissing();
return null;
}
};
// ── 사본 — 단가산출 연결 끊기 · 다시 잇기 · 끊은 문서 고침 ──
const changeCut = (k: string, fn: (d: FormulaDoc) => void): void => {
if (!cut[k]) return;
fn(cut[k]);
cache.merge({ 문서: { [k]: cut[k] } });
keep();
recalc();
draw();
};
const onCut = (k: string): void => {
const from = subDoc(k);
if (!from) return;
cut = { ...cut, [k]: clone(from) };
changeCut(k, () => undefined);
};
const onJoin = async (k: string): Promise<void> => {
if (!(await showConfirmDialog(ft("Ask_Relink"), ft("Relink")))) return;
const { [k]: _gone, ...rest } = cut;
cut = rest;
if (calls[k]) cache.merge({ 문서: { [k]: calls[k] } });
else asked = ""; // 부름 문서를 아직 모름 — 뒤에서 서버에 물음
keep();
recalc();
draw();
};
const sub = (id: string): HTMLElement | null =>
subBlock(id, {
doc,
answer,
cache,
...(cp
? {
copy: {
edit: opt.admin,
has: (k: string) => k in calls || k in cut,
cut: (k: string) => cut[k],
onCut,
onJoin: (k: string) => void onJoin(k),
change: changeCut,
},
}
: {}),
});
// ── 그림 ──
/** 옵션 가로 — M01 = 화면 값만(`given`) · 사본 = 문서 값(SK 고른 값 · 저장) */
const optionBar = (): HTMLElement[] =>
buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
const at = doc?.변수?.indexOf(v) ?? -1; // 옵션 열쇠 ≠ 기호 — 그 정의 자리에 값
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
given[k] = x;
recalc();
draw();
});
/** 위 도구 줄 — 결과 기호(금액 줄 없을 때) · 고침 표 · 사본 표 · [꺼진 줄] · [고침 버림] · [수식 저장] */
const toolRow = (edit: boolean): HTMLElement => {
const bits: HTMLElement[] = [];
if (edit && doc && !(doc.금액 ?? []).length) {
const f = createInputField({
placeholder: ft("Result_Sym"),
value: doc.결과 ?? "",
compact: true,
size: 24,
});
f.input.addEventListener("change", () =>
change((d) => {
const t = f.input.value.trim();
if (t) d.결과 = t;
else delete d.결과;
}),
);
f.root.classList.add("fdoc__resultsym");
bits.push(f.root);
}
if (dirty()) bits.push(createBadge(ft("Dirty"), "warning"));
if (cp) bits.push(...copyBadges(mark, opt.admin));
const off = doc && answer ? offLines(doc, answer).size : 0;
const busy = dirty() || !!rows.editing();
// 편집 중 누름 — 편집기 커서를 안 뺏음(저장 · 버림이 편집 중 글을 먼저 받음)
const hold = (b: HTMLButtonElement): HTMLButtonElement => {
b.addEventListener("mousedown", (ev) => ev.preventDefault());
return b;
};
const tools = edit
? [
...(off
? [
createButton({
label: `${showOff ? ft("Hide_Off") : ft("Show_Off")} ${off}`,
variant: "ghost",
size: 28,
onClick: () => {
showOff = !showOff;
draw();
},
}),
]
: []),
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
hold(
createButton({
label: ft("Discard"),
variant: "ghost",
size: 28,
disabled: !busy,
onClick: () => void discard(),
}),
),
hold(
createButton({
label: ft("Save"),
variant: "filled",
size: 28,
disabled: !busy,
onClick: () => void save(),
}),
),
]
: [];
return el("div", {
className: "fdoc__sum",
children: [
el("div", { className: "fdoc__sumleft", children: bits }),
el("div", { className: "fdoc__tools", children: tools }),
],
});
};
const draw = (): void => {
rows.beforeDraw();
if (!doc || !answer) {
[head, grid, side, foot].forEach((x) => x.replaceChildren());
return;
}
// M01 저장 전 새 로직(키 없음)도 고침 — [수식 저장] · [고침 버림]은 키가 생긴 뒤(페이지 [저장] → 새 키로 이어 감)
// M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만
const edit = opt.admin && (!!key || !cp);
const vals = answer.값 ?? {};
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lineSet = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean));
const shown = reach(doc, vals, showOff);
const markMap = new Map<string, string>();
for (const m of answer.표지 ?? []) {
const at = m.기호 || (m as { 자리?: string }).자리;
if (at && !markMap.has(at)) markMap.set(at, m.글);
}
const options = optionBar();
head.replaceChildren(
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
toolRow(edit && !!key),
);
const { cells, order } = buildLines({
admin: edit,
showOff,
doc,
answer,
vars,
marks: markMap,
editing: rows.editing(),
mount: rows.mount,
onEditAt: rows.editAt,
onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)),
onPickVar,
onGroupName: (old, now) =>
change((d) =>
(d.금액 ?? []).forEach((l) => {
if ((l.묶음 ?? "") !== old) return;
if (now) l.묶음 = now;
else delete l.묶음;
}),
),
onRedraw: () => redraw(),
});
rows.setOrder(order);
const defs = buildDefs({
admin: edit,
vars,
names: cache.names,
lines: lineSet,
shown,
unused: [...vars.keys()].filter((id) => !shown.includes(id)),
need: new Set(
shown.filter((id) => !lineSet.has(id) && (vars.get(id)?.연결?.갈래 ?? "식") === "식"),
),
open: openDef,
onOpen: (id) => {
openDef = id;
draw();
},
setLink: (id, link: Link) =>
change((d) => {
const clean = Object.fromEntries(Object.entries(link).filter(([, x]) => x !== undefined));
ensureVar(d, id).연결 = clean as Link;
}),
setVar: (id, patch) => change((d) => Object.assign(ensureVar(d, id), patch)),
rename: (id, sign, low, up) => {
const copy = clone(doc!);
try {
rename(copy, id, sign, low, up);
} catch (e) {
if (e instanceof DocError) return ft("Bad_Sign");
throw e;
}
openDef = varId({ 기호: sign, 아래: low, 위: up });
change((d) => Object.assign(d, copy));
return null;
},
dropVar: (id) => {
openDef = null;
change((d) => {
d.변수 = (d.변수 ?? []).filter((v) => varId(v) !== id);
d.식 = (d.식 ?? []).filter((l) => l.기호 !== id);
});
},
makeLine: (id) => {
if (lineSet.has(id)) return;
const i = (doc!.식 ?? []).length;
change((d) => (d.식 ??= []).push({ 기호: id, 식: "" }));
rows.editAt({ kind: "line", i });
},
ask: (kind, words, group) =>
call<{ 후보: Pick[]; 더있음: boolean }>("/links", { 갈래: kind, 검색: words, 분류: group }),
table: tableOf,
subInputs: (k) =>
(subDoc(k)?.변수 ?? [])
.filter((v) => ["선택", "입력", "숫자"].includes(v.연결?.갈래 ?? "") && v.열쇠)
.map((v) => v.열쇠!),
sub,
});
const symHead = el("div", {
className: "fdoc__colhead fdoc__symhead",
children: [
el("span", { text: ft("Sym_Head") }),
...(rows.editing() ? [mathToolbar(rows.editingMoney() ? rows.splitGroup : undefined)] : []),
],
});
grid.replaceChildren(
symHead,
el("div", { className: "fdoc__colhead", text: ft("Val_Head") }),
...cells,
);
side.replaceChildren(defs);
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
};
/** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */
const redraw = (): void => {
if (!doc) return;
const before = JSON.stringify(doc);
sweep(doc);
if (JSON.stringify(doc) !== before) {
keep();
recalc();
}
draw();
};
const take = (got: Loaded): void => {
cache.merge(got.연결);
saved = JSON.stringify(got.문서);
doc = clone(got.문서);
if (!cp) return;
calls = got.부름 ?? {};
cut = clone(got.끊음 ?? {});
savedCut = JSON.stringify(cut);
mark = got.사본;
version = got.판 ?? "";
};
const body = (): Record<string, unknown> =>
cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc };
/** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */
const runJob = async (job: CopyJob): Promise<void> => {
if (!doc || !cp) return;
if (dirty()) return void showToast(ft("Dirty_First"), "warning");
showLoadingOverlay();
let got: JobReply;
try {
got = await call<JobReply>("/preview", { 할일: job });
} catch (e) {
hideLoadingOverlay();
return void showToast(
`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`,
"error",
);
}
hideLoadingOverlay();
const names = new Map(Object.entries({ ...calls, ...cut }).map(([k, d]) => [k, d.이름 ?? ""]));
if (await askJob(job, got, names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
};
const save = async (send?: Record<string, unknown>, done = ft("Saved")): Promise<void> => {
if (!doc) return;
rows.flush();
if (!send && !dirty()) return void draw();
send ??= body();
showLoadingOverlay();
try {
const got = await call<Loaded>("/save", send).catch(async (e: { status?: number }) => {
// 사본 판 바뀜(페이지 [저장] 등) — 수식 사본이 그대로면 새 판으로 한 번 더
if (!cp || e.status !== 409) throw e;
const now = await call<Loaded>("");
if (
JSON.stringify([now.문서, now.끊음 ?? {}]) !==
JSON.stringify([JSON.parse(saved), JSON.parse(savedCut)])
)
throw e;
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
});
take(got);
keep();
recalc();
draw();
if (cp && got.판) cp.onSaved?.(got.판);
showToast(done, "success");
} catch (e) {
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
} finally {
hideLoadingOverlay();
}
};
const back = (): void => {
doc = JSON.parse(saved) as FormulaDoc;
cut = JSON.parse(savedCut) as Docs;
cache.merge({ 문서: { ...calls, ...cut } });
};
const discard = async (): Promise<void> => {
rows.reset();
if (!doc || !dirty()) return void draw();
back();
openDef = null;
keep();
recalc();
draw();
};
/** 문서 열기 — 같은 키면 그대로(다시 그림만) */
const open = async (next: string): Promise<void> => {
if (next === key && doc) return;
rows.reset();
const was = key;
key = next;
doc = answer = null;
given = {};
showOff = false;
openDef = null;
asked = "";
cache = createDocCache();
tables.clear();
[cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""];
draw();
if (!next) {
// 저장 전 새 로직 — 세 영역 빈 화면(누르면 입력 · 고친 것은 캐시 "" 열쇠)
doc = blankDoc();
saved = JSON.stringify(doc);
keep(); // 옛 새 로직 고침(「""」 열쇠) 걷음
recalc();
return draw();
}
const mine = ++seq;
try {
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
if (mine !== seq || key !== next) return;
take(got);
const all = drafts();
// 저장 전 새 로직에서 고친 문서 — 페이지 [저장]으로 생긴 새 키(빈 문서)로 이어 감
if (!was && got.새 && all[""] && !all[next]) {
all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 };
delete all[""];
sessionStorage.setItem(opt.cache, JSON.stringify(all));
}
const draft = all[next];
if (draft) doc = clone(draft);
const kept = cp ? (drafts()[`${next}#끊음`] as unknown as Docs | undefined) : undefined;
if (kept) cache.merge({ 문서: (cut = clone(kept)) });
recalc();
draw();
} catch (e) {
if (mine === seq)
head.replaceChildren(
el("p", {
className: "ui-text ui-text--muted",
text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`,
}),
);
}
};
const canLeave = async (): Promise<boolean> => {
rows.flush();
if (!dirty()) return true;
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
back();
keep();
return true;
};
return {
root,
/** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */
draw: (next: string): void => void open(next),
canLeave,
/** 시험 · 확인용 — 지금 문서 · 셈 */
state: () => ({ doc, answer, dirty: dirty() }),
};
}