Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts
T
eomsangdonandClaude Opus 5.5 43784a4c09 wip(801-1): 수식 그 자리 고침 · 한 마디 줄 · 로직 부름 기호 · 반올림 · 안 탄 갈래 숨김 — 요구 664 로 멈춤(메인 안 합침)
- 서버: 한 마디 줄(표 찾기 · 요소 · 로직 부름 하나) = 줄 기호 · 「적용 · 값 · 기준」 칸 글 대신 조건 값 기호 · 로직 부름 기호(master_equation_sign) · 값 식 숫자 단위 · 있는 계산 변수 연결 바꿈 = 그 줄 식 · 식 속 원자 키보드 글(입력글)
- 화면: 박스 없는 조판 · 반올림 감싸개 · 안 탄 만약 숨김(감싸개 글로 구조 지킴) · 보이는 식 그 자리 고침(contenteditable · 원자 data-tok) · 값 숫자 그 자리 고침(valueEdit)
- 시험 test_801_1 7 통과 · 넓은 왕복 199 로직 중 13 걸림(식 300 자 상한 · 요소 EQ/MO 글 · 표 칸 Q 기호 맞물림) · test_796_2 옛 createFormulaCell 기대 안 고침(멈춤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:50:36 +09:00

646 lines
24 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.
/* =============================================================================
* M01_MasterData_UI_LogicLab_Equation.ts
* M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이).
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
* 고침(801) = 보이는 조판 식 그 자리에서(`ui_template_equation_edit` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
* · 값 식 숫자 = 그 변수 값(`valueEdit` — 입력 = 옵션 값 · 중간 = 그 줄 식 · 수 · 표 찾기 · 요소 = 이 식의 그 자리 수)
* · 기호를 누르면 정의 줄이 열림(이름 · 기호 · 첨자 · 계산 변수는 연결 바꿈)
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createEqChain } from "@ui/ui_template_equation_line";
import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs";
import { createEqSum } from "@ui/ui_template_equation_sum";
import {
eqText,
type EqLine,
type EqSumHooks,
type EqNode,
type EqTree,
} from "@ui/ui_template_equation";
import {
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import type { FormulaVerdict } from "../A00_Common/sheet_formula_cell";
import { editEq } from "@ui/ui_template_equation_edit";
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
import {
draftRow,
renameEditor,
type Draft,
type DefsHost,
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import {
formulaEdit,
inputsOf,
unknownOf,
valueEdit,
type EqEdit,
} from "./logic/M01_Logic_Equation_Model";
import "./M01_MasterData_UI_LogicLab_Equation.css";
const TEXT = {
Title: ["수식", "Equation"],
Save: ["수식 저장", "Save equation"],
Discard: ["수식 고침 버림", "Discard edits"],
Add: ["변수 더함", "Add variable"],
Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"],
EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"],
DiscardPage: ["수식 원래로", "Back to copy"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before"],
Blocked: [
"시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)",
"Unsaved edits elsewhere — save or discard first (showing saved version)",
],
Options: ["옵션", "Options"],
Wait: ["수식을 불러오는 중", "Loading equation"],
Hint: [
"식을 눌러 그 자리 고침(+ − × ÷ / ( ) _ ^ · Enter 넣음 · Esc 버림) · 기호 = 연결 · 숫자 = 값",
"Click a formula to edit in place · symbol = link · number = value",
],
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
Leave_Ok: ["버림", "Discard"],
Saved: ["수식을 저장함", "Equation saved"],
Total: ["계", "Total"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT, n = 0): string =>
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n));
interface Read {
ok?: boolean;
reason?: string;
수식?: EqTree;
되나?: boolean;
까닭?: string[];
줄?: LogicRow;
전?: Record<string, unknown>;
후?: Record<string, unknown>;
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) throw new ApiError(r.status, data.detail ?? r.statusText);
return data;
}
const CACHE = "m01_equation_edits";
const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록)
const loadCache = (): Record<string, EqEdit[]> => {
try {
return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record<string, EqEdit[]>;
} catch {
return {};
}
};
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
export interface EqPage {
ask: <T>(
suffix: "" | "/preview" | "/links",
body: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<T>;
edits: () => EqEdit[];
keep: (edits: EqEdit[]) => void;
values: () => Record<string, string>;
setValue: (name: string, value: string | null) => void;
/** 소계 표 덧단추 자리(796-2e M02 더한 기초 [연결 끊기]) */
sum?: EqSumHooks;
}
export interface LabEquationOptions {
/** 시스템 관리자 — 고침 도구 */
admin: boolean;
/** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */
blocked: () => boolean;
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
onSaved: () => void;
/** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */
page?: EqPage;
}
/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */
interface PendingLink {
line: EqLine;
text: string;
symbols: string[];
rows: Map<string, Draft>;
apply: HTMLElement;
}
export function createLabEquation(opt: LabEquationOptions) {
const bar = el("div", { className: "m01-eq__bar" });
const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } });
const notice = el("div", { className: "m01-eq__notice" });
const body = el("div", { className: "m01-eq__body" });
const root = el("div", {
className: "m01-eq",
attrs: { "data-lab-equation": "" },
children: [bar, optionsBar, notice, body],
});
let key = "";
let version = "";
let row: LogicRow | null = null;
let rowNow: LogicRow | null = null;
let eq: EqTree | null = null;
let edits: EqEdit[] = [];
let inputs: Record<string, string> = {};
let before: Record<string, unknown> | null = null;
let why = "";
let editingLine: string | null = null;
let editAt: { x: number; y: number } | null = null;
let defEditing: string | null = null;
let pending: PendingLink | null = null;
let defsBox: HTMLElement = el("div");
let ctl: AbortController | null = null;
let timer = 0;
let paintedBlocked = false;
const open = new Set<string>();
const canEdit = (): boolean => opt.admin && !opt.blocked();
const page = opt.page;
const vals = (): Record<string, string> => page?.values() ?? inputs;
const ask = <T>(
suffix: "" | "/preview" | "/links",
body: Record<string, unknown>,
signal?: AbortSignal,
) =>
page ? page.ask<T>(suffix, body, signal) : post<T>(`/logic/equation${suffix}`, body, signal);
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(vals()), 고침: edits });
const persist = (): void => {
if (page) return page.keep(edits);
const all = loadCache();
if (edits.length) all[key] = edits;
else delete all[key];
try {
sessionStorage.setItem(CACHE, JSON.stringify(all));
} catch {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
};
/** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */
async function refresh(): Promise<void> {
ctl?.abort();
const mine = (ctl = new AbortController());
try {
const got = await ask<Read>(edits.length ? "/preview" : "", body0(), mine.signal);
if (mine !== ctl) return;
if (got.되나 === false || got.ok === false) {
why = (got.까닭 ?? [got.reason ?? ""]).join(" · ");
} else {
why = "";
eq = got.수식 ?? null;
rowNow = got.줄 ?? null;
before = edits.length ? (got.전 ?? null) : null;
}
} catch (error) {
if (mine.signal.aborted) return;
why = error instanceof Error ? error.message : String(error);
}
paint();
}
/** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */
async function commit(list: EqEdit[]): Promise<string[]> {
const got = await ask<Read>("/preview", {
...body0(),
고침: [...edits, ...list],
}).catch((error: unknown): Read => ({
되나: false,
까닭: [error instanceof Error ? error.message : String(error)],
}));
if (!got.되나) return got.까닭 ?? [];
edits = [...edits, ...list];
persist();
ctl?.abort();
why = "";
eq = got.수식 ?? eq;
rowNow = got.줄 ?? rowNow;
before = got.전 ?? before;
paint();
drawOptions();
return [];
}
const askAgain = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void refresh(), 150);
};
// ── 옵션 가로 ──────────────────────────────────────────────────────
function optionNode(spec: NonNullable<LogicRow["입력"]>[number], auto: unknown): HTMLElement {
const item: ItemInput = {
이름: spec.이름,
단위: spec.단위,
고르기: spec.고르기,
범위: spec.범위,
자동값: auto,
};
const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12);
const set = (v: string | number | null): void => {
if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v));
if (v === null || v === "") delete inputs[spec.이름];
else inputs[spec.이름] = String(v);
askAgain();
};
let control: HTMLElement;
if ((spec.고르기?.length ?? 0) > BIG) {
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
const box = createInputField({
value: vals()[spec.이름] ?? "",
compact: true,
size: 24,
placeholder: auto === null || auto === undefined ? "" : String(auto),
});
const list = el("datalist", {
attrs: { id: `m01-eq-list-${spec.이름}` },
children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })),
});
box.input.setAttribute("list", list.id);
box.input.title = box.input.value || box.input.placeholder;
box.input.addEventListener("change", () => {
box.input.title = box.input.value;
set(box.input.value.trim());
});
control = el("div", { children: [box.root, list] });
} else {
const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v));
const tip = (): void => {
const c = made.control;
c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value;
};
made.control.addEventListener("change", tip);
made.control.addEventListener("input", tip);
tip();
control = el("div", { children: [made.control, made.error] });
}
control.classList.add("m01-eq__ctl");
const cell = el("div", { className: "m01-eq__opt", children: [label, control] });
cell.dataset.eqOption = spec.이름;
return cell;
}
function drawOptions(): void {
const specs = (rowNow ?? row)?.입력 ?? [];
const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v]));
optionsBar.replaceChildren(
...(specs.length
? [
createToolbar({
items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)),
size: 24,
label: t("Options"),
}),
]
: []),
);
}
// ── 바로 고침 · 연결 지정 ──────────────────────────────────────────
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
const defsHost: DefsHost = {
commit,
links: (body) => ask("/links", body),
onDropped: (k) => {
if (page) page.setValue(k, null);
else {
delete inputs[k];
drawOptions();
}
},
};
function buildDefs(): HTMLElement {
const edit = canEdit();
const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠);
return createEqDefs(eq?.정의 ?? [], {
editing: defEditing,
onEdit: edit
? (d) => {
defEditing = defEditing === defSign(d) ? null : defSign(d);
drawDefs();
}
: undefined,
editor: (d) =>
renameEditor(d, defsHost, () => {
defEditing = null;
drawDefs();
}),
drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [],
head: edit
? [
createButton({
label: t("Add"),
variant: "ghost",
size: 24,
iconStart: "plus",
onClick: () => openAddDialog({ mids, commit }),
}),
]
: [],
});
}
function drawDefs(): void {
const next = buildDefs();
defsBox.replaceWith(next);
defsBox = next;
}
/** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */
function setPending(line: EqLine, text: string, miss: string[]): void {
if (!miss.length) {
if (!pending) return;
pending = null;
return drawDefs();
}
if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) {
[pending.line, pending.text] = [line, text];
return;
}
const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const));
const apply = el("div", {
className: "m01-eq__def-actions",
children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })],
});
pending = { line, text, symbols: miss, rows, apply };
drawDefs();
defsBox.scrollTo({ top: 0 });
}
async function applyPending(): Promise<void> {
const p = pending;
if (!p) return;
const list: EqEdit[] = [];
let missing = false;
for (const d of p.rows.values()) {
const made = d.read();
d.say(typeof made === "string" ? made : "");
if (typeof made === "string") missing = true;
else list.push(made);
}
if (missing) return;
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
[pending, editingLine] = [null, null];
paint();
}
const check =
(line: EqLine) =>
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
const got = await ask<Read>(
"/preview",
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
signal,
);
const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []);
setPending(line, text, miss);
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
return {
되나: !!got.되나,
까닭: miss.length
? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`]
: (got.까닭 ?? []),
알림: got.되나 && now ? [`${t("Total")} ${now}`] : [],
식: got.되나 ? text : null,
};
};
/** 수식 영역 — 그린 식 그 자리 고침 · 칠 때마다 미리 셈 · Enter = 고침 목록에 굳힘 */
function editor(line: EqLine, _id: string, host: HTMLElement): void {
const say = el("span", { className: "ui-eq__bad" });
host.closest(".ui-eq__name")?.after(say);
const close = (): void => {
[editingLine, pending] = [null, null];
paint();
};
const show = (ok: boolean, list: string[]): void => {
host.dataset.bad = ok ? "" : "1";
say.className = ok ? "ui-eq__note" : "ui-eq__bad";
say.textContent = list.join(" · ");
};
let mine: AbortController | null = null;
editEq(host, {
at: editAt,
onInput: (text) => {
mine?.abort();
const me = (mine = new AbortController());
check(line)(text, me.signal)
.then((v) => me === mine && show(v.되나, (v.되나 ? v.알림 : v.까닭) ?? []))
.catch(() => undefined);
},
onCommit: (text) => {
if (pending) return void applyPending();
void commit([formulaEdit(line, text)]).then((bad) =>
bad.length ? show(false, bad) : close(),
);
},
onCancel: () => (pending ? undefined : close()),
});
}
/** 값 숫자 고침 — 입력 = 옵션 값 · 그 밖 = 고침 목록 */
function onValue(line: EqLine, leaf: EqNode, text: string): void {
const got = valueEdit(line, leaf, text);
if (typeof got === "string") return showToast(got, "error");
if ("input" in got) {
if (page) return page.setValue(got.input, text);
inputs[got.input] = text;
drawOptions();
return askAgain();
}
void commit(got.edits).then((bad) => bad.length && showToast(bad.join(" · "), "error"));
}
/** 기호를 누름 — 정의 줄로 · 고칠 수 있으면 그 줄 고침 열림 */
function showVar(node: EqNode): void {
const sign = defSign({
기호: node.기호 || node.열쇠 || "",
위: typeof node.위 === "string" ? node.위 : "",
아래: typeof node.아래 === "string" ? node.아래 : "",
});
const def = eq?.정의?.find((d) => defSign(d) === sign);
if (canEdit() && def?.고침 && defEditing !== sign) {
defEditing = sign;
drawDefs();
}
flashEqDef(defsBox, node);
}
// ── 그림 ──────────────────────────────────────────────────────────
function drawBar(): void {
const sums = eq?.합계 ?? {};
const mark = total(sums);
const now = sums[mark];
const old = before?.[mark];
const diff = Number(now) - Number(old);
const items: (HTMLElement | "divider" | "spacer")[] = [
createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14),
];
if (before) {
items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14));
if (Number.isFinite(diff) && diff)
items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning"));
}
if (canEdit()) {
items.push("spacer");
if (edits.length) {
items.push(
createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"),
createButton({
label: t(page ? "DiscardPage" : "Discard"),
variant: "ghost",
size: 24,
onClick: () => void discard(),
}),
...(page
? []
: [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]),
);
} else items.push(createText(t("Hint"), "muted", 12));
}
bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") }));
}
function paint(): void {
paintedBlocked = opt.blocked();
drawBar();
notice.replaceChildren(
...(paintedBlocked && opt.admin
? [createBanner({ tone: "warning", text: t("Blocked") })]
: []),
...(why ? [createBanner({ tone: "danger", text: why })] : []),
);
if (!eq) {
body.replaceChildren(createText(t("Wait"), "muted"));
return;
}
const edit = canEdit();
const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠);
const sum = createEqSum(eq, open, page?.sum, keys);
const left = el("div", {
className: "m01-eq__left",
children: [
createEqChain(eq.줄, {
path: "",
open,
resultUnit: eq.결과단위,
onVar: showVar, // 기호 = 정의 줄(연결) · 그 밖 식 자리 = 그 자리 고침
editing: editingLine,
onEdit: edit
? (_line, id, at) => {
[editingLine, editAt] = [id, at];
paint();
}
: undefined,
editor: edit ? editor : undefined,
onValue: edit ? onValue : undefined,
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
}),
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
],
});
defsBox = buildDefs();
body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] }));
}
async function save(): Promise<void> {
if (!canEdit() || !edits.length) return;
showLoadingOverlay();
try {
const done = await saveRevised((ok) =>
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
);
if (!done) return; // 개정 확인 취소 = 고침 그대로
[edits, editingLine] = [[], null];
persist();
showToast(t("Saved"), "success");
await refresh();
drawOptions();
opt.onSaved();
} catch (error) {
const detail =
error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null;
showToast(
detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)),
"error",
);
} finally {
hideLoadingOverlay();
}
}
async function discard(): Promise<void> {
edits = [];
[editingLine, defEditing, pending] = [null, null, null];
persist();
await refresh();
drawOptions();
}
return {
root,
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈
version = nextVersion;
row = nextRow;
if (nextKey !== key) {
[key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null];
[defEditing, pending] = [null, null];
inputs = {};
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
open.clear();
paint();
drawOptions();
void refresh().then(drawOptions);
} else if (moved) {
if (page) edits = page.edits();
void refresh().then(drawOptions);
} else if (opt.blocked() !== paintedBlocked) paint();
},
dirty: (): boolean => edits.length > 0,
/** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */
canLeave: async (): Promise<boolean> => {
if (!edits.length) return true;
if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false;
await discard();
return true;
},
};
}