Files
Aislo/A00_Common/formula_doc_math.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

381 lines
12 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_math.ts
* 수식 영역 그 자리 편집기(PLAN 803-2a · 요구 667-4 · 6) — 발표 자료 수식처럼 치는 대로 조판(꼴 = `formula_doc_math_model.ts`)
* 누른 줄이 편집기로 바뀜 · 누른 자리에 커서 · 숨은 글 칸(한글 조합 · 붙여넣기)이 키를 받음
* ← → = 구조 안팎 · ↑ ↓ = 분수 위아래 → 밖이면 윗줄 · 아랫줄 · Enter = 새 줄 · Esc · 다른 데 누름 = 나옴 · Ctrl+A = 줄 전체
* 도구 줄(`mathToolbar`) = 편집 중일 때만 영역 위 — 분수 · 첨자 · 괄호 · × ÷ % · ≤ ≥ ≠ · 만약 · 반올림
* 고친 글은 나올 때 부른 쪽 `onLeave(글, 어떻게)` 로 — 여기선 문서를 안 고침
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { createToolbar } from "@ui/ui_template_toolbar";
import {
backspace,
del,
fromLinear,
isOp,
left,
right,
toLinear,
typeChar,
vertical,
type Caret,
type Item,
} from "./formula_doc_math_model";
import { ft, type TextKey } from "./formula_doc_text";
export type Leave = "exit" | "enter" | "up" | "down" | "back";
export interface MathHooks {
/** 처음 글(선형 · 공용 `serializeEq` 꼴) */
text: string;
/** 커서 — 누른 자리 · 맨 앞 · 맨 끝(기본) */
at?: { x: number; y?: number } | "start" | "end";
onInput?: (text: string) => void;
/** 나옴 — 글 · 어떻게 · 커서 가로 자리(위아래 줄 옮김) */
onLeave: (text: string, how: Leave, x?: number) => void;
}
export interface MathEditor {
text: () => string;
/** 부른 쪽이 닫음(훅 안 부름) */
close: () => void;
command: (name: Command) => void;
}
export type Command =
"frac" | "sub" | "sup" | "paren" | "×" | "÷" | "%" | "≤" | "≥" | "≠" | "if" | "round";
let active: MathEditor | null = null;
// 누르는 중 편집기를 떠나면 — 놓은 뒤(그 누름이 먼저 돌고) 나옴
let pressing = false;
let afterPress: (() => void) | null = null;
if (typeof document !== "undefined") {
document.addEventListener("pointerdown", () => (pressing = true), true);
document.addEventListener(
"pointerup",
() => {
pressing = false;
const run = afterPress;
afterPress = null;
if (run) window.setTimeout(run, 0);
},
true,
);
}
/** 지금 열린 편집기(도구 줄이 부름) */
export const activeMath = (): MathEditor | null => active;
const LATIN = /^[A-Za-z]$/;
const NUMERIC = /^[\d.,]$/;
/** 줄(`host`)을 편집기로 */
export function openMath(host: HTMLElement, h: MathHooks): MathEditor {
active?.close();
host.replaceChildren();
host.dataset.editing = "1";
host.classList.add("fdoc-math");
const root: Item[] = [];
fromLinear(h.text, root);
let caret: Caret = h.at === "start" ? { list: root, i: 0 } : { list: root, i: root.length };
let all = false;
let closed = false;
let composing = "";
let used = 0; // 숨은 칸에서 이미 받은 글자 수
const view = el("span", { className: "fdoc-math__view" });
const sink = el("textarea", {
className: "fdoc-math__sink",
attrs: { "aria-label": ft("Sym_Head"), spellcheck: "false", autocomplete: "off", rows: "1" },
});
host.append(view, sink);
const boxes = new Map<Item[], HTMLElement>();
const lists = new WeakMap<Element, Item[]>();
const chars = new WeakMap<Element, Caret>();
// ── 그림 ──
const fill = (list: Item[]): HTMLElement => {
const box = el("span", { className: "fdoc-math__row" });
boxes.set(list, box);
lists.set(box, list);
list.forEach((it, i) => box.append(node(it, list, i)));
if (!list.length && list !== root) box.classList.add("is-empty");
return box;
};
const node = (it: Item, list: Item[], i: number): HTMLElement => {
if (it.t === "ch") {
const cls = isOp(it)
? "ui-eq__op"
: NUMERIC.test(it.c)
? "ui-eq__num"
: LATIN.test(it.c)
? "fdoc-math__latin"
: "fdoc-math__ch";
const s = el("span", { className: cls, text: it.c === "==" ? "=" : it.c });
if (it.c === " ") s.textContent = " ";
chars.set(s, { list, i });
return s;
}
if (it.t === "frac")
return el("span", {
className: "ui-eq__frac",
children: [
el("span", { className: "ui-eq__top", children: [fill(it.num)] }),
el("span", { className: "ui-eq__bottom", children: [fill(it.den)] }),
],
});
if (it.t === "paren")
return el("span", {
className: "ui-eq__paren",
children: [el("span", { className: "ui-eq__inner", children: [fill(it.body)] })],
});
const s = el(it.t, { className: "ui-eq__script" });
s.append(fill(it.body));
return s;
};
const caretX = (): number =>
host.querySelector(".fdoc-math__caret")?.getBoundingClientRect().x ?? 0;
const render = (): void => {
boxes.clear();
view.replaceChildren(fill(root));
host.classList.toggle("is-all", all);
const box = boxes.get(caret.list) ?? boxes.get(root)!;
const mark = el("span", { className: "fdoc-math__caret" });
box.insertBefore(mark, box.children[caret.i] ?? null);
if (composing)
box.insertBefore(el("span", { className: "fdoc-math__ime", text: composing }), mark);
const r = mark.getBoundingClientRect();
const o = host.getBoundingClientRect();
sink.style.transform = `translate(${r.x - o.x}px, ${r.y - o.y}px)`;
};
const changed = (): void => {
render();
h.onInput?.(toLinear(root));
};
// ── 누른 자리 → 커서 ──
const nearest = (list: Item[], x: number): Caret => {
const box = boxes.get(list)!;
const kids = [...box.children].filter(
(k) => chars.has(k) || !k.className.startsWith("fdoc-math__"),
);
const at = kids.findIndex((k) => {
const r = k.getBoundingClientRect();
return x < r.x + r.width / 2;
});
return { list, i: at < 0 ? list.length : at };
};
const pointAt = (x: number, y: number): Caret => {
for (let t = document.elementFromPoint(x, y); t && host.contains(t); t = t.parentElement) {
const c = chars.get(t);
if (c) {
const r = t.getBoundingClientRect();
return x < r.x + r.width / 2 ? c : { list: c.list, i: c.i + 1 };
}
const list = lists.get(t);
if (list) return nearest(list, x);
}
return nearest(root, x);
};
// ── 치기 ──
const typeText = (text: string, linear = false): void => {
if (!text) return;
if (all) {
root.length = 0;
caret = { list: root, i: 0 };
all = false;
}
if (linear) caret = fromLinear(text, root, caret);
else for (const k of text) caret = typeChar(root, caret, k);
changed();
};
const leave = (how: Leave): void => {
if (closed) return;
const x = caretX();
closed = true;
if (active === editor) active = null;
h.onLeave(toLinear(root), how, x);
};
const clearAll = (): void => {
root.length = 0;
caret = { list: root, i: 0 };
all = false;
changed();
};
sink.addEventListener("compositionupdate", (ev) => {
composing = ev.data;
render();
});
sink.addEventListener("compositionend", (ev) => {
composing = "";
typeText(ev.data);
used = sink.value.length;
});
sink.addEventListener("input", (ev) => {
if ((ev as InputEvent).isComposing) return;
used = Math.min(used, sink.value.length);
const got = sink.value.slice(used);
used = sink.value.length;
typeText(got);
});
sink.addEventListener("paste", (ev) => {
ev.preventDefault();
typeText(ev.clipboardData?.getData("text") ?? "", true);
});
sink.addEventListener("keydown", (ev) => {
if (ev.isComposing || ev.keyCode === 229) return;
const mod = ev.ctrlKey || ev.metaKey;
const key = ev.key;
if (!mod && key.length === 1) {
sink.value = ""; // 다음 글자만 받음(조합 중이 아님)
used = 0;
return;
}
const stop = (): void => ev.preventDefault();
if (mod) {
const k = key.toLowerCase();
if (k === "a") {
stop();
all = true;
render();
} else if (k === "c" || k === "x") {
stop();
void navigator.clipboard?.writeText(toLinear(root));
if (k === "x") clearAll();
} else if (k === "z" || k === "y") stop();
return;
}
const move = (to: Caret): void => {
stop();
all = false;
caret = to;
render();
};
switch (key) {
case "Enter":
stop();
return leave("enter");
case "Escape":
case "Tab":
stop();
return leave("exit");
case "ArrowLeft":
return move(left(root, caret));
case "ArrowRight":
return move(right(root, caret));
case "ArrowUp":
case "ArrowDown": {
const v = vertical(root, caret, key === "ArrowUp");
if (v) return move(v);
stop();
return leave(key === "ArrowUp" ? "up" : "down");
}
case "Home":
return move({ list: root, i: 0 });
case "End":
return move({ list: root, i: root.length });
case "Backspace": {
stop();
if (all) return clearAll();
const to = backspace(root, caret);
if (!to) return leave("back");
caret = to;
return changed();
}
case "Delete":
stop();
if (all) return clearAll();
caret = del(root, caret);
return changed();
}
});
sink.addEventListener("blur", () => {
const settle = (): void => {
if (!closed && host.isConnected && document.activeElement !== sink) leave("exit");
};
if (pressing) afterPress = settle;
else window.setTimeout(settle, 0);
});
host.addEventListener("mousedown", (ev) => {
if (closed) return;
ev.preventDefault();
ev.stopPropagation();
all = false;
caret = pointAt(ev.clientX, ev.clientY);
render();
sink.focus();
});
const editor: MathEditor = {
text: () => toLinear(root),
close: () => {
closed = true;
if (active === editor) active = null;
},
command: (name) => {
if (closed) return;
if (all) {
root.length = 0;
caret = { list: root, i: 0 };
all = false;
}
if (name === "frac") {
const f: Item = { t: "frac", num: [], den: [] };
caret.list.splice(caret.i, 0, f);
caret = { list: f.num, i: 0 };
} else if (name === "sub" || name === "sup")
caret = typeChar(root, caret, name === "sub" ? "_" : "^");
else if (name === "paren") caret = typeChar(root, caret, "(");
else if (name === "if" || name === "round") {
for (const k of name === "if" ? "만약(" : "반올림(") caret = typeChar(root, caret, k);
if (name === "if") caret.list.push({ t: "ch", c: "," }, { t: "ch", c: "," });
} else caret = typeChar(root, caret, name);
changed();
sink.focus();
},
};
active = editor;
render();
if (h.at && typeof h.at === "object") {
const at = h.at;
const y = at.y ?? view.getBoundingClientRect().y + view.getBoundingClientRect().height / 2;
caret = pointAt(at.x, y);
render();
}
sink.focus({ preventScroll: true });
return editor;
}
const TOOLS: [Command, TextKey | ""][] = [
["frac", "Tool_Frac"],
["sub", "Tool_Sub"],
["sup", "Tool_Sup"],
["paren", "Tool_Paren"],
["×", ""],
["÷", ""],
["%", ""],
["≤", ""],
["≥", ""],
["≠", ""],
["if", "Tool_If"],
["round", "Tool_Round"],
];
/** 수식 도구 줄 — 영역 위 · 누름이 편집기 커서를 안 뺏음 · `group` = 금액 줄 편집 중 [묶음](여기부터 새 묶음) */
export function mathToolbar(group?: () => void): HTMLElement {
const button = (label: string, onClick: () => void): HTMLButtonElement => {
const b = createButton({ label, variant: "ghost", size: 24, onClick });
b.addEventListener("mousedown", (ev) => ev.preventDefault());
return b;
};
const items: (HTMLElement | "divider")[] = TOOLS.map(([cmd, label]) =>
button(label ? ft(label) : cmd, () => active?.command(cmd)),
);
if (group) items.push("divider", button(ft("Tool_Group"), group));
const bar = createToolbar({ items, size: 24, label: ft("Tools") });
bar.classList.add("fdoc-math__tools");
return bar;
}