- 서버: 한 마디 줄(표 찾기 · 요소 · 로직 부름 하나) = 줄 기호 · 「적용 · 값 · 기준」 칸 글 대신 조건 값 기호 · 로직 부름 기호(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>
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
}
|