Files
Aislo/A00_Common/formula_doc_rows.ts
T
eomsangdonandClaude Opus 5.5 f5545dd62b feat(803-2a): 수식 탭 세 영역 · 하나의 수식 영역 · 합계 상자 · 설명 글 지움
- 세 영역 가로 — 기호 식(줄바꿈으로 이어진 하나의 수식 영역) | 값 식(같은 높이 줄) | 기호 정의
- 맨 아래 합계 상자 = 단가산출 상세 탭 부품(노무비 · 재료비 · 경비 · 계) · 옵션 아래 계 줄 · 설명 글 · 표지 안내 줄 걷음(오류 = 그 기호 빨강)
- 기호 식 줄 누름 = 그 자리 편집기(치는 대로 조판 — / 분수 · _ 아래첨자 · ^ 위첨자 · 괄호 · 화살표 구조 이동 · Enter 새 줄 · Esc 나옴 · 한글 조합) · 편집 중만 영역 위 수식 도구 줄
- 금액 줄 = 영역 안 식 줄 「노무비 형틀목공 = w × Q」(머리 첫 말 = 비목) · 「+ 식 줄」 「+ 금액 줄」 「+ 묶음」 · 비목 고르기 칸 걷음 · 묶음 = 도구 줄 [묶음]
- 저장 전 새 로직 = 세 영역 빈 화면 · 끊은 단가산출 블록도 같은 편집기
- 새 라이브러리 없음 · 서버 길 · 계산기 · 문서 꼴 그대로

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

200 lines
8.1 KiB
TypeScript

/* =============================================================================
* formula_doc_rows.ts
* 수식 영역 줄 편집 흐름(PLAN 803-2a · 요구 667-4 · 6) — 어느 줄을 편집 중인지 · 줄 사이 옮김 · 새 줄 · 문서에 넣기
* 줄 누름 = 편집 중 글을 넣고 그 줄을 엶 · Enter = 아래 새 줄(금액 줄 뒤면 같은 비목 「노무비 」로 시작) ·
* ↑ ↓ = 옆 줄(가로 자리 그대로) · 빈 줄 맨 앞 지움 = 줄 걷고 윗줄 끝 · Esc · 밖 누름 = 나옴 · 빈 줄은 나올 때 걷음
* 글 → 문서 = `editRow`(식 줄 · 금액 줄 「노무비 형틀목공 = w × Q」 · 갈래가 바뀌면 옮김)
* 그림 · 셈은 부른 쪽(`formula_doc_screen.ts` — `change` · `draw`) · 편집기는 `formula_doc_math.ts`
* ========================================================================== */
import type { FormulaDoc, MoneyLine } from "../common_util/common_util_formula_doc";
import { editRow, splitLine, sweep } from "./formula_doc_edit";
import type { RowRef } from "./formula_doc_lines";
import { openMath, type Leave, type MathEditor, type MathHooks } from "./formula_doc_math";
import { tryParse } from "./formula_doc_render";
import { ft } from "./formula_doc_text";
interface Editing {
ref: RowRef;
at?: MathHooks["at"];
/** 처음 글(새 금액 줄 「노무비 」) */
seed?: string;
}
interface Current {
ref: RowRef;
first: string;
ed: MathEditor | null;
}
export interface RowsHost {
doc: () => FormulaDoc | null;
/** 문서 고침 → 캐시 · 셈 · 그림 */
change: (fn: (d: FormulaDoc) => void) => void;
/** 고침 없이 캐시 · 셈(그림 없음) */
settle: () => void;
draw: () => void;
root: HTMLElement;
}
export function createRows(host: RowsHost) {
let editing: Editing | null = null;
let current: Current | null = null;
let order: RowRef[] = [];
let live: { ref: RowRef; text: string; first: string } | null = null; // 다시 그림 사이 편집 중 글
const doc = (): FormulaDoc | null => host.doc();
const rowObj = (r: RowRef): object | undefined =>
(r.kind === "line" ? doc()?.식 : doc()?.금액)?.[r.i];
const refOf = (o: object | null | undefined): RowRef | null => {
const a = (doc()?.식 ?? []).indexOf(o as never);
if (a >= 0) return { kind: "line", i: a };
const b = (doc()?.금액 ?? []).indexOf(o as never);
return b >= 0 ? { kind: "money", i: b } : null;
};
/** 편집 중 글을 문서에 — 돌려줌 = 그 줄 객체 */
const commit = (d: FormulaDoc, c: Current, text: string): object | null =>
text === c.first ? (rowObj(c.ref) ?? null) : editRow(d, c.ref.kind, c.ref.i, text);
/** 새 줄 — `after` 뒤(식 줄 = 식 줄 · 금액 줄 = 같은 비목 · 묶음) · null = 영역 맨 끝 */
const newRow = (d: FormulaDoc, after: object | null): { obj: object; seed: string } => {
const lines = (d.식 ??= []);
const money = (d.금액 ??= []);
const li = after ? lines.indexOf(after as never) : -1;
const mi = after ? money.indexOf(after as MoneyLine) : -1;
const base = mi >= 0 ? money[mi] : li < 0 ? money[money.length - 1] : undefined;
if (!base) {
const o = { 기호: "", 식: "" };
lines.splice(li >= 0 ? li + 1 : lines.length, 0, o);
return { obj: o, seed: "" };
}
const kind = base.비목 ?? "노무비";
const o: MoneyLine = {
이름: "",
비목: kind,
단가: "",
...(base.묶음 ? { 묶음: base.묶음 } : {}),
};
money.splice(mi >= 0 ? mi + 1 : money.length, 0, o);
return { obj: o, seed: `${kind} ` };
};
const target = (obj: object | null, at?: MathHooks["at"], seed?: string): void => {
const ref = refOf(obj);
editing = ref ? { ref, at, ...(seed ? { seed } : {}) } : null;
};
const take = (): Current | null => {
const c = current;
current = live = null;
c?.ed?.close();
return c;
};
/** 줄 누름 — 편집 중 글을 넣고 그 줄(또는 맨 끝 새 줄)을 엶 */
const editAt = (ref: RowRef | "end", at?: { x: number; y: number }): void => {
const c = take();
const want = ref === "end" ? null : (rowObj(ref) ?? null);
host.change((d) => {
if (c) commit(d, c, c.ed?.text() ?? c.first);
const made = ref === "end" ? newRow(d, null) : null;
sweep(d, made?.obj ?? want);
target(made?.obj ?? want, at ?? "end", made?.seed);
});
};
/** 편집기에서 나옴 — Enter = 새 줄 · ↑ ↓ = 옆 줄 · 지움 = 빈 줄 걷고 윗줄 끝 */
const finish = (c: Current, text: string, how: Leave, x?: number): void => {
if (current === c) current = null;
live = null;
const at = order.findIndex((r) => r.kind === c.ref.kind && r.i === c.ref.i);
const near =
how === "down" ? order[at + 1] : how === "up" || how === "back" ? order[at - 1] : undefined;
const nearObj = near ? (rowObj(near) ?? null) : null;
const d = doc();
if (how === "exit" && text === c.first && d) {
editing = null;
const before = JSON.stringify(d);
sweep(d);
if (JSON.stringify(d) !== before) host.settle();
// 값 식 숫자 등 다른 자리를 고치는 중이면 그대로(다음 그림에서 정리)
if (!host.root.querySelector("[data-editing]:not(.fdoc-math)")) host.draw();
return;
}
host.change((dd) => {
const mine = commit(dd, c, text);
const made = how === "enter" ? newRow(dd, mine) : null;
sweep(dd, made?.obj ?? nearObj);
const pos: MathHooks["at"] =
how === "up" || how === "down" ? { x: x ?? 0 } : how === "back" ? "end" : "start";
target(made?.obj ?? nearObj, pos, made?.seed);
});
};
return {
editing: (): RowRef | null => editing?.ref ?? null,
editingMoney: (): boolean => editing?.ref.kind === "money",
setOrder: (o: RowRef[]): void => void (order = o),
editAt,
/** 다시 그리기 앞 — 편집 중 글을 기억(같은 줄을 다시 열 때 이어 씀) */
beforeDraw: (): void => {
const c = current;
if (!c?.ed) return;
live = { ref: c.ref, text: c.ed.text(), first: c.first };
c.ed.close();
current = null;
},
/** 편집 중 글을 문서에 넣고 닫음(그림 없음 · 저장 · 나가기 앞) */
flush: (): void => {
const c = take();
editing = null;
const d = doc();
if (!c || !d) return;
commit(d, c, c.ed?.text() ?? c.first);
sweep(d);
host.settle();
},
/** 편집 버림(문서 바뀜 · 고침 버림) */
reset: (): void => {
take();
editing = null;
},
mount: (el: HTMLElement, ref: RowRef, text: string): void => {
const e = editing;
if (!e) return;
const again = live && live.ref.kind === ref.kind && live.ref.i === ref.i ? live : null;
live = null;
const first = again?.first ?? e.seed ?? text;
const c: Current = { ref, first, ed: null };
current = c;
queueMicrotask(() => {
if (current !== c || !el.isConnected) return;
c.ed = openMath(el, {
text: again?.text ?? first,
at: again ? "end" : (e.at ?? "end"),
onInput: (t) => {
const body = splitLine(t)[1];
el.toggleAttribute("data-bad", !!body.trim() && !tryParse(body));
},
onLeave: (t, how, x) => finish(c, t, how, x),
});
});
},
/** 편집 중 금액 줄부터 그 묶음 끝까지 새 묶음(이름은 머리를 눌러 고침) */
splitGroup: (): void => {
const c = current;
if (!c || c.ref.kind !== "money") return;
take();
host.change((d) => {
const mine = commit(d, c, c.ed?.text() ?? c.first) as MoneyLine | null;
const money = d.금액 ?? [];
const at = mine ? money.indexOf(mine) : -1;
if (at < 0) return void (editing = null);
const old = mine!.묶음 ?? "";
const taken = new Set(money.map((l) => l.묶음 ?? ""));
let name = ft("New_Group");
for (let n = 2; taken.has(name); n++) name = `${ft("New_Group")} ${n}`;
for (let k = at; k < money.length && (money[k].묶음 ?? "") === old; k++)
money[k].묶음 = name;
target(mine, "end");
});
},
};
}