Files
Aislo/ui_template/ui_template_equation_edit.ts
T
eomsangdonandClaude Opus 5.5 43784a4c09 wip(801-1): 수식 그 자리 고침 · 한 마디 줄 · 로직 부름 기호 · 반올림 · 안 탄 갈래 숨김 — 요구 664 로 멈춤(메인 안 합침)
- 서버: 한 마디 줄(표 찾기 · 요소 · 로직 부름 하나) = 줄 기호 · 「적용 · 값 · 기준」 칸 글 대신 조건 값 기호 · 로직 부름 기호(master_equation_sign) · 값 식 숫자 단위 · 있는 계산 변수 연결 바꿈 = 그 줄 식 · 식 속 원자 키보드 글(입력글)
- 화면: 박스 없는 조판 · 반올림 감싸개 · 안 탄 만약 숨김(감싸개 글로 구조 지킴) · 보이는 식 그 자리 고침(contenteditable · 원자 data-tok) · 값 숫자 그 자리 고침(valueEdit)
- 시험 test_801_1 7 통과 · 넓은 왕복 199 로직 중 13 걸림(식 300 자 상한 · 요소 EQ/MO 글 · 표 칸 Q 기호 맞물림) · test_796_2 옛 createFormulaCell 기대 안 고침(멈춤)

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

137 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/* =============================================================================
* 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<HTMLElement>("[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);
}