/* ============================================================================= * 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(); const lists = new WeakMap(); const chars = new WeakMap(); // ── 그림 ── 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; }