/* ============================================================================= * ui_template_equation_edit.ts * 부품 「수식」 바로 고침(801-1 · 요구 662-1 · 2) — 보이는 조판 수식 그 자리에서 고침(따로 열리는 칸 · 창 없음). * 기호 식 = `editEq(host, …)` — 그린 식이 그대로 글 고침 자리(커서 · 지움 · + − × ÷ / ( ) · `_` 아래첨자 · `^` 위첨자) * · 변수 · 표 찾기 · 요소 · 로직 부름 · 숨긴 감싸개 = 원자(통째 지움 · 누르면 연결) · Enter 넣음 · Esc 버림 * · 분수 · 첨자 꼴은 넣은 뒤 서버 나무로 다시 그림(`/` = 분수 · `Q_d` = 첨자) * 값 식 = `editNumber(span, …)` — 숫자 하나를 그 자리에서(Enter · 다른 데 누름 = 넣음 · Esc 버림) * 글 꼴 = `serializeEq` — 그림 DOM → 키보드 한 줄 식(원자 `data-tok` · 감싸개 `data-pre` · `data-post` · 분수 · 괄호) * 상태 = 고치는 자리 `data-editing` · 틀린 식 `data-bad`(빨강 밑줄) * ========================================================================== */ const OP = /^[×÷−+\-*/=<>≤≥≠,]$|^(그리고|또는)$/; /** 숫자 글 → 키보드 수(천 단위 쉼표 걷음) */ const plain = (text: string): string => text.replace(/ /g, " ").replace(/(\d),(?=\d{3}(?!\d))/g, "$1"); /** 그림 DOM → 키보드 한 줄 식 */ export function serializeEq(node: Node): string { if (node.nodeType === Node.TEXT_NODE) return plain(node.textContent ?? ""); if (!(node instanceof HTMLElement)) return ""; const kids = (el: Element | null): string => el ? [...el.childNodes].map(serializeEq).join("") : ""; const d = node.dataset; if (d.tok !== undefined) return ` ${d.tok} `; if (d.pre !== undefined) return `${d.pre}${kids(node)}${d.post ?? ""}`; const cls = node.classList; if (cls.contains("ui-eq__unit") || node.tagName === "BR") return ""; if (cls.contains("ui-eq__frac")) { const [top, low] = [node.children[0] ?? null, node.children[1] ?? null]; return `(${kids(top)}) / (${kids(low)})`; } if (cls.contains("ui-eq__paren")) return `(${kids(node)})`; if (cls.contains("ui-eq__op")) { const op = node.textContent ?? ""; return OP.test(op) ? ` ${op} ` : op; } return kids(node); } /** 한 줄 식 글 다듬기(빈칸 하나로) */ export const tidyEq = (text: string): string => text.replace(/\s+/g, " ").trim(); export interface EqEditHooks { /** 칠 때마다(쉼 뒤) — 검사 · 미리 셈 */ onInput: (text: string) => void; /** Enter — 넣음 */ onCommit: (text: string) => void; /** Esc · 안 바꾸고 다른 데 누름 — 닫음 */ onCancel: () => void; /** 누른 자리(그 자리에 커서) */ at?: { x: number; y: number } | null; } /** 그린 식(`host` 안)을 그 자리 고침으로 — 처음 글 돌려줌 */ export function editEq(host: HTMLElement, h: EqEditHooks): string { host.contentEditable = "true"; host.spellcheck = false; host.dataset.editing = "1"; host.querySelectorAll("[data-tok]").forEach((a) => { a.contentEditable = "false"; }); const first = tidyEq(serializeEq(host)); const now = (): string => tidyEq(serializeEq(host)); let timer = 0; host.addEventListener("beforeinput", (ev) => { if (ev.inputType === "insertText" && ev.data === "*") { ev.preventDefault(); document.execCommand("insertText", false, "×"); } if (ev.inputType === "insertParagraph" || ev.inputType === "insertLineBreak") ev.preventDefault(); }); host.addEventListener("input", () => { window.clearTimeout(timer); timer = window.setTimeout(() => h.onInput(now()), 250); }); host.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); window.clearTimeout(timer); h.onCommit(now()); } else if (ev.key === "Escape") { ev.preventDefault(); h.onCancel(); } }); host.addEventListener("focusout", () => window.setTimeout(() => { if (!host.isConnected || host.contains(document.activeElement)) return; if (now() === first) h.onCancel(); }, 150), ); queueMicrotask(() => { host.focus(); const sel = window.getSelection(); const pt = h.at; const range = pt && document.caretRangeFromPoint ? document.caretRangeFromPoint(pt.x, pt.y) : null; if (range && host.contains(range.startContainer)) { sel?.removeAllRanges(); sel?.addRange(range); } else { sel?.selectAllChildren(host); sel?.collapseToEnd(); } }); return first; } /** 값 식 숫자 하나를 그 자리에서 고침 — 바뀐 글이면 `done(새 글)` */ export function editNumber(num: HTMLElement, done: (text: string) => void): void { const before = plain(num.textContent ?? ""); num.textContent = before; num.contentEditable = "true"; num.dataset.editing = "1"; let over = false; const finish = (keep: boolean): void => { if (over) return; over = true; num.contentEditable = "false"; delete num.dataset.editing; const text = plain(num.textContent ?? "").trim(); if (keep && text && text !== before) done(text); else num.textContent = before; }; num.addEventListener("keydown", (ev) => { if (ev.key === "Enter" || ev.key === "Escape") { ev.preventDefault(); finish(ev.key === "Enter"); } }); num.addEventListener("blur", () => finish(true), { once: true }); num.focus(); window.getSelection()?.selectAllChildren(num); }