- 세 영역 가로 — 기호 식(줄바꿈으로 이어진 하나의 수식 영역) | 값 식(같은 높이 줄) | 기호 정의 - 맨 아래 합계 상자 = 단가산출 상세 탭 부품(노무비 · 재료비 · 경비 · 계) · 옵션 아래 계 줄 · 설명 글 · 표지 안내 줄 걷음(오류 = 그 기호 빨강) - 기호 식 줄 누름 = 그 자리 편집기(치는 대로 조판 — / 분수 · _ 아래첨자 · ^ 위첨자 · 괄호 · 화살표 구조 이동 · Enter 새 줄 · Esc 나옴 · 한글 조합) · 편집 중만 영역 위 수식 도구 줄 - 금액 줄 = 영역 안 식 줄 「노무비 형틀목공 = w × Q」(머리 첫 말 = 비목) · 「+ 식 줄」 「+ 금액 줄」 「+ 묶음」 · 비목 고르기 칸 걷음 · 묶음 = 도구 줄 [묶음] - 저장 전 새 로직 = 세 영역 빈 화면 · 끊은 단가산출 블록도 같은 편집기 - 새 라이브러리 없음 · 서버 길 · 계산기 · 문서 꼴 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|