diff --git a/A00_Common/formula_doc.css b/A00_Common/formula_doc.css deleted file mode 100644 index 0b60f1e2d..000000000 --- a/A00_Common/formula_doc.css +++ /dev/null @@ -1,411 +0,0 @@ -/* 수식 문서 화면(802-3 · 803-2a) — 세 영역 가로(기호 식 수식 영역 | 값 식 | 기호 정의) · 맨 아래 합계 상자 · 색은 공통 값만 */ -.fdoc { - gap: var(--spacing-12); -} - -.fdoc__head { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.fdoc__options { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-12); - align-items: flex-end; -} - -.fdoc__options > * { - width: auto; - min-width: 8em; -} - -.fdoc__sum { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8); -} - -.fdoc__sumleft, -.fdoc__tools { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.fdoc__resultsym { - width: 9em; -} - -/* 세 영역 — 기호 식 · 값 식(격자 두 칸 · 줄마다 같은 높이) | 기호 정의 */ -.fdoc__areas { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - gap: var(--spacing-24); - align-items: start; -} - -.fdoc__grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 0 var(--spacing-24); - align-items: stretch; -} - -.fdoc__side { - min-width: 0; -} - -/* 기호 식 = 하나의 수식 영역 · 값 식 = 같은 높이 줄 — 줄 칸이 이어진 한 상자씩 */ -.fdoc__area, -.fdoc__val { - min-height: 32px; - padding: 2px var(--spacing-8); - border-inline: 1px solid var(--color-border); -} - -.fdoc__area { - background: var(--color-surface-sunken); -} - -.fdoc__area.is-top, -.fdoc__val.is-top { - border-top: 1px solid var(--color-border); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - padding-top: var(--spacing-8); -} - -.fdoc__area.is-bottom, -.fdoc__val.is-bottom { - border-bottom: 1px solid var(--color-border); - border-radius: 0 0 var(--radius-sm) var(--radius-sm); - padding-bottom: var(--spacing-8); -} - -.fdoc__area.is-top.is-bottom, -.fdoc__val.is-top.is-bottom { - border-radius: var(--radius-sm); -} - -.fdoc__blank { - cursor: text; -} - -.fdoc__blank.is-alone, -.fdoc__blank.is-alone + .fdoc__val { - min-height: 120px; -} - -.fdoc__symhead { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - min-height: 32px; -} - -.fdoc__kindtag { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.fdoc__colhead { - align-self: end; - color: var(--color-text-secondary); - font-size: var(--text-caption); - border-bottom: 1px solid var(--color-border); - padding-bottom: var(--spacing-4); -} - -.fdoc__cell { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0 var(--spacing-4); - min-width: 0; - min-height: 32px; -} - -.fdoc__sym, -.fdoc__valcell { - cursor: text; -} - -.fdoc__edit { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 0 var(--spacing-4); - min-width: 2em; - min-height: 24px; - border-radius: var(--radius-sm); - padding: 0 2px; -} - -.fdoc__edit:hover { - background: var(--color-hover); -} - -.fdoc__edit[data-editing] { - outline: 1px solid var(--color-accent); - background: var(--color-accent-soft); -} - -.fdoc__edit[data-bad], -.fdoc__money[data-bad] .fdoc__name { - text-decoration: underline wavy var(--color-danger); -} - -.fdoc__raw { - color: var(--color-danger); -} - -.fdoc__atom.is-val { - cursor: pointer; -} - -.fdoc__atom.is-val:hover { - outline: 1px solid var(--color-accent); -} - -.fdoc__atom.is-missing { - color: var(--color-danger); -} - -.fdoc__lit { - cursor: pointer; -} - -.fdoc__lit[data-editing] { - outline: 1px solid var(--color-accent); -} - -.fdoc__dash { - color: var(--color-text-muted); -} - -/* 금액 줄 · 묶음 */ -.fdoc__money { - align-items: flex-start; -} - -.fdoc__valcell.fdoc__money { - flex-direction: column; - align-items: flex-start; -} - -.fdoc__mrow { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 0 var(--spacing-4); -} - -.fdoc__name { - font-weight: var(--font-weight-medium, 500); - border-radius: var(--radius-sm); -} - -.fdoc__name[data-editing] { - outline: 1px solid var(--color-accent); - padding: 0 var(--spacing-4); -} - -.fdoc__cost, -.fdoc__tk { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.fdoc__tk { - margin-left: var(--spacing-8); -} - -.fdoc__amount { - font-weight: var(--font-weight-medium, 500); -} - -.fdoc__grouphead { - padding-top: var(--spacing-8); -} - -.fdoc__group { - font-weight: var(--font-weight-medium, 500); -} - -.fdoc__total { - border-top: 1px solid var(--color-border); -} - -/* 기호 정의 — 오른쪽 영역 한 줄씩(머리 아래 한 상자) */ -.fdoc__defs { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.fdoc__defs > .fdoc__colhead { - min-height: 32px; - display: flex; - align-items: flex-end; -} - -.fdoc__defs > .fdoc__colhead + * { - margin-top: var(--spacing-4); -} - -.fdoc__side { - min-height: 120px; -} - -.fdoc__defrow { - border-radius: var(--radius-sm); -} - -.fdoc__defrow.is-open { - background: var(--color-surface-sunken); - padding-bottom: var(--spacing-8); -} - -.fdoc__def { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4); - padding: 2px var(--spacing-4); - cursor: pointer; - border-radius: var(--radius-sm); -} - -.fdoc__def:hover { - background: var(--color-hover); -} - -.fdoc__deflink { - font-size: var(--text-body-sm); -} - -.fdoc__defedit { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8); -} - -.fdoc__fields { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - align-items: flex-end; -} - -.fdoc__fields > * { - width: auto; -} - -.fdoc__fields .ui-find { - width: 100%; - max-height: 220px; -} - -.fdoc__unused summary { - cursor: pointer; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - padding: var(--spacing-4); -} - -.fdoc__sub { - margin: var(--spacing-4) var(--spacing-8); - padding: var(--spacing-8); - border-left: 2px solid var(--color-accent); -} - -.fdoc__wrap, -.fdoc__val { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 0 var(--spacing-4); -} - -/* 수식 영역 편집기(803-2a · `formula_doc_math.ts`) — 치는 대로 조판 · 커서 · 숨은 글 칸 */ -.fdoc-math { - position: relative; - cursor: text; -} - -/* 편집 중 줄 = 그 줄 너비 전부(빈 줄도 입력 칸이 보임 · 줄은 넘칠 때만 꺾임) */ -.fdoc__edit.fdoc-math, -.fdoc__edit.fdoc-math > .fdoc-math__view, -.fdoc__edit.fdoc-math > .fdoc-math__view > .fdoc-math__row { - flex: 1 1 auto; -} - -.fdoc-math__view, -.fdoc-math__row { - display: inline-flex; - flex-wrap: nowrap; - align-items: center; -} - -.fdoc-math__view > .fdoc-math__row { - flex-wrap: wrap; -} - -.fdoc-math .ui-eq__op { - padding: 0 var(--spacing-4); -} - -.fdoc-math__latin { - font-style: italic; - font-family: "Cambria Math", "STIX Two Math", "Times New Roman", serif; -} - -.fdoc-math__row.is-empty { - min-width: 0.8em; - min-height: 1.1em; - outline: 1px dashed var(--color-border-strong); -} - -.fdoc-math__caret { - display: inline-block; - width: 0; - height: 1.2em; - margin-right: -1.5px; - border-left: 1.5px solid var(--color-text-body); - animation: fdoc-math-blink 1s steps(1) infinite; -} - -@keyframes fdoc-math-blink { - 50% { - opacity: 0; - } -} - -.fdoc-math__ime { - text-decoration: underline; -} - -.fdoc-math.is-all .fdoc-math__view { - background: var(--color-accent-soft); -} - -.fdoc-math__sink { - position: absolute; - top: 0; - left: 0; - width: 1px; - height: 1em; - padding: 0; - border: 0; - opacity: 0; - resize: none; - overflow: hidden; - pointer-events: none; -} diff --git a/A00_Common/formula_doc_defs.ts b/A00_Common/formula_doc_defs.ts deleted file mode 100644 index de7029ed2..000000000 --- a/A00_Common/formula_doc_defs.ts +++ /dev/null @@ -1,372 +0,0 @@ -/* ============================================================================= - * formula_doc_defs.ts - * 수식 문서 기호 정의 영역(PLAN 802-3 · 요구 660-2 · 660-5 · 660-6 · 664) — 값 식 아래 「기호 : 국문 이름 — 연결」 한 줄씩 - * 같은 기호 한 번 · 값 · 단위 · DB 키 없음 · 지금 탄 갈래로 닿는 기호만(나머지는 접힌 「쓰지 않는 기호」) - * 식에 새 기호를 쓰면 「정의 필요」 줄이 생김 → 눌러 연결 고름(그 줄 안에서 · 창 없음): - * 숫자 · 선택(목록) · 입력(범위) · 요소(인력 · 재료 · 기계 찾기) · 표(표 · 조건 식 · 칸) · 단가산출(다른 기초 · 넘길 입력 · 칸) · 식(식 줄) - * 기호 · 아래 · 위 첨자 · 국문 이름 · 단위 고침 · 정의 지움(쓰는 자리는 「정의 필요」로 남음) - * 고친 것은 부른 쪽 훅으로 — 여기선 문서를 안 고침 - * ========================================================================== */ - -import { createButton } from "@ui/ui_template_button"; -import { createBadge } from "@ui/ui_template_badge"; -import { el } from "@ui/ui_template_elements"; -import { createFindList, type FindRow } from "@ui/ui_template_find_list"; -import { createInputField, createSelectField } from "@ui/ui_template_input"; -import { createSegment } from "@ui/ui_template_segment"; -import { LINKS, splitId, type DocVar, type Link } from "../common_util/common_util_formula_doc"; -import type { DocTable } from "../common_util/common_util_formula_doc_table"; -import { symNode } from "./formula_doc_render"; -import { ft } from "./formula_doc_text"; - -const PLAIN = new Set(["적용", "값", "기준"]); -const ELEM_KINDS = ["인력", "재료", "기계"]; - -export interface Pick { - 키: string; - 이름?: string; - 규격?: string; - 단위?: string; - 조건?: string[]; - 칸?: string[]; - 입력?: string[]; -} - -export interface DefsHooks { - admin: boolean; - vars: Map; - names: Map; - lines: Set; - shown: string[]; - unused: string[]; - need: Set; - open: string | null; - onOpen: (id: string | null) => void; - setLink: (id: string, link: Link) => void; - setVar: (id: string, patch: Partial) => void; - rename: (id: string, sign: string, low: string, up: string) => string | null; - dropVar: (id: string) => void; - makeLine: (id: string) => void; - ask: (kind: string, words: string, group: string) => Promise<{ 후보: Pick[]; 더있음: boolean }>; - table: (key: string) => Promise; - subInputs: (key: string) => string[]; - sub: (id: string) => HTMLElement | null; -} - -const span = (cls: string, text?: string): HTMLSpanElement => - el("span", { className: cls, ...(text !== undefined ? { text } : {}) }); - -/** 연결 글 — 갈래 + 대상 이름(키 · 값 · 단위 없음) */ -export function linkText( - v: DocVar | undefined, - names: Map, - isLine: boolean, -): string { - const link = v?.연결; - const kind = link?.갈래 ?? "식"; - if (kind === "식") return isLine ? "식" : ft("Need"); - const ref = String(link?.키 ?? link?.표 ?? ""); - const named = (r: string): string => - r.includes("{") ? r.replace(/^[A-Z]+:/, "").replace(/[{}]/g, "") : names.get(r) || ""; - const parts: string[] = [kind]; - if (kind === "요소" && link?.재료) { - const m = link.재료 as Record; - parts.push(String(m.검색어 ?? m.이름 ?? m.상세구분 ?? m.구분 ?? "")); - } else if (ref) parts.push(named(ref)); - if (link?.칸 && !PLAIN.has(String(link.칸))) parts.push(String(link.칸)); - return parts.filter(Boolean).join(" · "); -} - -function field(label: string, value: string, onChange: (v: string) => void, width = "") { - const f = createInputField({ label, value, compact: true, size: 24 }); - f.input.addEventListener("change", () => onChange(f.input.value)); - if (width) f.root.style.width = width; - return f; -} - -/** 찾아 고르기(요소 · 표 · 단가산출) */ -function finder(h: DefsHooks, kind: string, onPick: (p: Pick) => void, group = { now: "인력" }) { - const filters: HTMLElement[] = []; - if (kind === "요소") - filters.push( - createSegment({ - options: ELEM_KINDS.map((k) => ({ value: k, label: k })), - value: group.now, - size: 24, - onChange: (v) => { - group.now = v; - void run(); - }, - }).root, - ); - const list = createFindList({ placeholder: ft("Find"), filters }); - let timer = 0; - let seq = 0; - const run = async (): Promise => { - const mine = ++seq; - const words = list.search.value.trim(); - if (!words && kind !== "요소") return list.setRows([]); - const got = await h - .ask(kind === "단가산출" ? "단가산출" : kind, words, group.now) - .catch(() => null); - if (mine !== seq || !got) return; - list.hint.textContent = got.더있음 ? ft("More") : got.후보.length ? "" : ft("Nothing"); - list.setRows( - got.후보.map((p): FindRow => ({ - key: p.키, - name: p.이름 ?? "", - sub: p.규격 ?? "", - unit: p.단위 ?? "", - onPick: () => onPick(p), - })), - ); - }; - list.search.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void run(), 250); - }); - return list.root; -} - -function kindBody(id: string, v: DocVar | undefined, h: DefsHooks): HTMLElement[] { - const link: Link = { ...(v?.연결 ?? { 갈래: "식" }) }; - const kind = link.갈래 ?? "식"; - const set = (patch: Partial): void => h.setLink(id, { ...link, ...patch }); - const num = (s: string): number | string => { - const n = Number(s.replace(/,/g, "").trim()); - return s.trim() !== "" && Number.isFinite(n) ? n : s.trim(); - }; - switch (kind) { - case "숫자": - return [field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root]; - case "선택": { - const list = (link.목록 ?? []).map(String); - const pick = createSelectField({ - label: ft("Default"), - options: list.map((x) => ({ value: x, text: x })), - value: String(link.값 ?? ""), - compact: true, - size: 24, - onChange: (x) => set({ 값: link.목록?.find((y) => String(y) === x) ?? x }), - }); - const items = field(ft("List"), list.join(" · "), (s) => - set({ - 목록: s - .split(/\s*[·,]\s*/) - .filter(Boolean) - .map((x) => num(x)), - }), - ); - return [items.root, pick.root]; - } - case "입력": { - const [low, high] = link.범위 ?? [null, null]; - const bound = (i: 0 | 1) => (s: string) => { - const r: [number | null, number | null] = [...(link.범위 ?? [null, null])] as never; - const n = num(s); - r[i] = typeof n === "number" ? n : null; - set(r[0] === null && r[1] === null ? { 범위: undefined } : { 범위: r }); - }; - return [ - field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root, - field(ft("Low_Bound"), low === null ? "" : String(low), bound(0), "7em").root, - field(ft("High_Bound"), high === null ? "" : String(high), bound(1), "7em").root, - ]; - } - case "요소": { - const out: HTMLElement[] = []; - if (link.재료) - out.push( - span("ui-text ui-text--muted", `${ft("Material")} · ${linkText(v, h.names, false)}`), - ); - out.push( - finder(h, "요소", (p) => { - h.names.set(p.키, [p.이름, p.규격].filter(Boolean).join(" ")); - h.setLink(id, { 갈래: "요소", 키: p.키, ...(link.지역 ? { 지역: link.지역 } : {}) }); - }), - field(ft("Column"), String(link.칸 ?? ""), (s) => set({ 칸: s.trim() || undefined }), "9em") - .root, - field( - ft("Region"), - String(link.지역 ?? ""), - (s) => set({ 지역: s.trim() || undefined }), - "9em", - ).root, - ); - return out; - } - case "표": { - const out: HTMLElement[] = [ - finder(h, "표", (p) => { - h.names.set(p.키, p.이름 ?? ""); - void h - .table(p.키) - .then(() => h.setLink(id, { 갈래: "표", 표: p.키, 조건: {}, 칸: p.칸?.[0] ?? "" })); - }), - ]; - const tbl = link.표 ? h.table(String(link.표)) : null; - const cols = el("div", { className: "fdoc__fields" }); - out.push(cols); - void tbl?.then((t) => { - if (!t) return; - const names = [ - ...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]), - ].filter((k) => !(k in (t.조건 ?? {}))); - cols.append( - createSelectField({ - label: ft("Column"), - options: names.map((x) => ({ value: x, text: x })), - value: String(link.칸 ?? ""), - compact: true, - size: 24, - onChange: (x) => set({ 칸: x }), - }).root, - ...Object.keys(t.조건 ?? {}).map((c) => { - const seen = [...new Set(t.줄.map((r) => r[c]).filter((x) => typeof x === "string"))]; - const f = field(`${ft("Cond")} ${c}`, String(link.조건?.[c] ?? ""), (s) => { - const box = { ...(link.조건 ?? {}) }; - if (s.trim()) box[c] = s.trim(); - else delete box[c]; - set({ 조건: box }); - }); - f.input.placeholder = seen - .slice(0, 4) - .map((x) => `'${x}'`) - .join(" · "); - return f.root; - }), - ); - }); - return out; - } - case "단가산출": { - const ins = link.키 ? h.subInputs(String(link.키)) : []; - return [ - finder(h, "단가산출", (p) => { - h.names.set(p.키, p.이름 ?? ""); - h.setLink(id, { 갈래: "단가산출", 키: p.키, 입력: {} }); - }), - createSelectField({ - label: ft("Column"), - options: [ - { value: "", text: ft("None") }, - ...["노무비", "재료비", "경비", "계"].map((x) => ({ value: x, text: x })), - ], - value: String(link.칸 ?? ""), - compact: true, - size: 24, - onChange: (x) => set({ 칸: x || undefined }), - }).root, - ...ins.map( - (k) => - field(`${ft("Inputs")} ${k}`, String(link.입력?.[k] ?? ""), (s) => { - const box = { ...(link.입력 ?? {}) }; - if (s.trim()) box[k] = s.trim(); - else delete box[k]; - set({ 입력: box }); - }).root, - ), - ]; - } - default: - return h.lines.has(id) - ? [span("ui-text ui-text--muted", ft("By_Line"))] - : [ - createButton({ - label: ft("Make_Line"), - variant: "ghost", - size: 24, - onClick: () => h.makeLine(id), - }), - ]; - } -} - -function editor(id: string, h: DefsHooks): HTMLElement { - const v = h.vars.get(id); - const [sign, low, up] = v ? [v.기호 ?? "", v.아래 ?? "", v.위 ?? ""] : splitId(id); - const box = el("div", { className: "fdoc__defedit" }); - const names = el("div", { className: "fdoc__fields" }); - const sym = { sign, low, up }; - const signF = field(ft("Sign"), sign, (s) => tryRename({ ...sym, sign: s.trim() }), "6em"); - const lowF = field(ft("Low"), low, (s) => tryRename({ ...sym, low: s.trim() }), "8em"); - const upF = field(ft("Up"), up, (s) => tryRename({ ...sym, up: s.trim() }), "6em"); - const tryRename = (now: typeof sym): void => { - const why = h.rename(id, now.sign, now.low, now.up); - if (why) signF.setError(why); - }; - names.append( - signF.root, - lowF.root, - upF.root, - field(ft("Name"), v?.이름 ?? "", (s) => h.setVar(id, { 이름: s.trim() })).root, - field(ft("Unit"), v?.단위 ?? "", (s) => h.setVar(id, { 단위: s.trim() }), "7em").root, - ); - const kind = v?.연결?.갈래 ?? "식"; - const kinds = createSegment({ - options: LINKS.map((k) => ({ value: k, label: k })), - value: kind, - size: 24, - ariaLabel: ft("Link"), - onChange: (k) => { - const base: Link = { 갈래: k }; - if (k === "숫자" || k === "입력") base.값 = ""; - if (k === "선택") Object.assign(base, { 목록: [], 값: "" }); - h.setLink(id, base); - if (k === "식" && !h.lines.has(id)) h.makeLine(id); - }, - }); - const body = el("div", { className: "fdoc__fields", children: kindBody(id, v, h) }); - const drop = createButton({ - label: ft("Drop_Var"), - variant: "danger-ghost", - size: 24, - onClick: () => h.dropVar(id), - }); - box.append(names, kinds.root, body, drop); - return box; -} - -function row(id: string, h: DefsHooks): HTMLElement { - const v = h.vars.get(id); - const need = h.need.has(id); - const head = el("div", { className: "fdoc__def", attrs: { "data-def": id } }); - const sym = span("fdoc__defsym"); - sym.append(symNode(id)); - head.append(sym, span("ui-eq__op", ":")); - if (need) head.append(createBadge(ft("Need"), "warning")); - else head.append(span("fdoc__defname", v?.이름 || "")); - const own = v?.이름 ?? ""; - // 이름에 이미 든 대상 이름 · 칸은 연결 글에서 뺌(같은 말 두 번 없음) - const text = linkText(v, h.names, h.lines.has(id)) - .split(" · ") - .filter((x, i) => i === 0 || !own.includes(x)) - .join(" · "); - if (!need) head.append(span("ui-text ui-text--muted fdoc__deflink", `— ${text}`)); - const wrap = el("div", { className: "fdoc__defrow", children: [head] }); - if (h.open === id) wrap.classList.add("is-open"); - head.addEventListener("click", () => h.onOpen(h.open === id ? null : id)); - if (h.open === id) { - const sub = h.sub(id); - if (sub) wrap.append(sub); - if (h.admin) wrap.append(editor(id, h)); - } - return wrap; -} - -/** 정의 영역 — 닿는 기호 줄 + 접힌 쓰지 않는 기호 */ -export function buildDefs(h: DefsHooks): HTMLElement { - const box = el("div", { className: "fdoc__defs" }); - box.append(el("div", { className: "fdoc__colhead", text: ft("Defs_Head") })); - h.shown.forEach((id) => box.append(row(id, h))); - if (h.unused.length) { - const fold = el("details", { - className: "fdoc__unused", - attrs: h.open && h.unused.includes(h.open) ? { open: "" } : {}, - children: [el("summary", { text: `${ft("Unused")} ${h.unused.length}` })], - }); - h.unused.forEach((id) => fold.append(row(id, h))); - box.append(fold); - } - return box; -} diff --git a/A00_Common/formula_doc_edit.ts b/A00_Common/formula_doc_edit.ts deleted file mode 100644 index 3abbb5806..000000000 --- a/A00_Common/formula_doc_edit.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* ============================================================================= - * formula_doc_edit.ts - * 수식 문서 고침(PLAN 802-3 · 802-4b-2) — 식 줄 · 금액 줄 · 값 식 숫자 고침을 문서에 얹음(화면 · fetch 없음) - * 수식 화면(`formula_doc_screen.ts`)과 끊은 단가산출 블록(`formula_doc_sub.ts`)이 같은 고침을 씀 - * 수식 영역 한 줄 글(803-2a) = `editRow`(식 줄 · 금액 줄 「노무비 형틀목공 = w × Q」) · 빈 줄 걷기 `sweep` - * ========================================================================== */ - -import { - goodId, - splitId, - textOf, - tidy, - varId, - type DocVar, - type FormulaDoc, - type MoneyLine, -} from "../common_util/common_util_formula_doc"; -import { swapLit, tryParse } from "./formula_doc_render"; - -/** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */ -export function splitLine(text: string): [string | null, string] { - const m = /^([^=<>!]*?)\s*(?!=])=(?!=)\s*([\s\S]*)$/.exec(text); - if (!m) return [null, text.trim()]; - return [m[1].trim(), m[2].trim()]; -} - -export const tidyText = (t: string): string => (t.trim() ? tidy(t.trim()) : ""); - -/** 기호 정의 — 없으면 「식」 갈래로 새로 */ -export function ensureVar(d: FormulaDoc, id: string): DocVar { - const have = (d.변수 ??= []).find((v) => varId(v) === id); - if (have) return have; - const [기호, 아래, 위] = splitId(id); - const v: DocVar = { 열쇠: id, 기호, 아래, 위, 이름: "", 단위: "", 연결: { 갈래: "식" } }; - d.변수.push(v); - return v; -} - -/** 식 줄 i 를 글로 — 빈 글 = 줄 지움 · 머리 바뀜 = 식 갈래 정의도 같이 */ -export function editLine(d: FormulaDoc, i: number, text: string): void { - const lines = (d.식 ??= []); - const line = lines[i]; - if (!line) return; - if (!text.trim()) { - lines.splice(i, 1); - return; - } - const [h, body] = splitLine(text); - const old = String(line.기호 ?? ""); - if (h !== null && h !== old) { - line.기호 = h; - const was = (d.변수 ?? []).find((v) => varId(v) === old && (v.연결?.갈래 ?? "식") === "식"); - if (was && goodId(h) && !(d.변수 ?? []).some((v) => varId(v) === h)) { - const [기호, 아래, 위] = splitId(h); - Object.assign(was, { 기호, 아래, 위 }); - } - } - line.식 = tidyText(body); -} - -/** 금액 줄 i 고침(null = 지움) — 단가 · 수량 글은 다듬음 · 빈 수량 = 칸 뺌 */ -export function editMoney(d: FormulaDoc, i: number, patch: Partial | null): void { - const lines = (d.금액 ??= []); - if (!patch) { - lines.splice(i, 1); - return; - } - const line = lines[i]; - if (!line) return; - for (const [k, v] of Object.entries(patch)) { - const text = typeof v === "string" && (k === "단가" || k === "수량") ? tidyText(v) : v; - if (k === "수량" && text === "") delete line.수량; - else (line as Record)[k] = text; - } -} - -/** 값 식 숫자 고침 — 식 줄 · 금액 줄 단가 · 수량 글의 at 번째 수 */ -export function editLit( - d: FormulaDoc, - target: "line" | "price" | "qty", - i: number, - at: number, - value: number, -): void { - if (target === "line") { - const line = d.식?.[i]; - if (line) line.식 = swapLit(String(line.식 ?? ""), at, value); - return; - } - const line = d.금액?.[i]; - if (!line) return; - const slot = target === "price" ? "단가" : "수량"; - line[slot] = swapLit(String(line[slot] ?? ""), at, value); -} - -const KINDS = ["노무비", "재료비", "경비", "묶음"]; - -/** 금액 줄 몸 → 단가 · 수량(맨 위 × 의 오른쪽 = 수량 · 없으면 단가만) */ -function splitMoney(body: string): { 단가: string; 수량: string } { - const n = tryParse(body); - if (n && n[0] === "bin" && n[1] === "*") return { 단가: textOf(n[2]), 수량: textOf(n[3]) }; - return { 단가: body, 수량: "" }; -} - -/** 수식 영역 한 줄 글 → 문서(803-2a) — 머리 첫 말이 비목이면 금액 줄 · 아니면 식 줄(갈래가 바뀌면 옮김) - * 빈 글 = 줄 지움 · 돌려줌 = 고친 뒤 그 줄 객체(지웠으면 null) */ -export function editRow( - d: FormulaDoc, - kind: "line" | "money", - i: number, - text: string, -): object | null { - const lines = (d.식 ??= []); - const money = (d.금액 ??= []); - const was = kind === "line" ? lines[i] : money[i]; - if (!was) return null; - if (!text.trim()) { - (kind === "line" ? lines : money).splice(i, 1); - return null; - } - const [h, body] = splitLine(text); - const words = (h ?? text).trim().split(/\s+/); - const cost = KINDS.includes(words[0]) ? words[0] : null; - if (!cost) { - if (kind === "line") { - editLine(d, i, text); - return lines[i]; - } - money.splice(i, 1); - lines.push({ 기호: "", 식: "" }); - editLine(d, lines.length - 1, text); - return lines[lines.length - 1]; - } - const patch = { - 비목: cost, - 이름: words.slice(1).join(" "), - ...splitMoney(h === null ? "" : body), - }; - if (kind === "money") { - editMoney(d, i, patch); - return money[i]; - } - lines.splice(i, 1); - const group = money.length ? money[money.length - 1].묶음 : undefined; - money.push({ 이름: "", 비목: cost, 단가: "", ...(group ? { 묶음: group } : {}) }); - editMoney(d, money.length - 1, patch); - return money[money.length - 1]; -} - -/** 빈 줄 걷기(`keep` 빼고) */ -export function sweep(d: FormulaDoc, keep?: object | null): void { - d.식 = (d.식 ?? []).filter((l) => l === keep || String(l.기호 ?? "") || String(l.식 ?? "")); - d.금액 = (d.금액 ?? []).filter((l) => l === keep || l.이름 || l.단가 || l.수량); -} diff --git a/A00_Common/formula_doc_lines.ts b/A00_Common/formula_doc_lines.ts deleted file mode 100644 index 031dcd9ca..000000000 --- a/A00_Common/formula_doc_lines.ts +++ /dev/null @@ -1,356 +0,0 @@ -/* ============================================================================= - * formula_doc_lines.ts - * 수식 문서 줄(PLAN 802-3 · 803-2a · 요구 660~667) — 왼쪽 「기호 식」 = 줄바꿈으로 이어진 하나의 수식 영역 · 가운데 「값 식」 같은 높이 - * 식 줄: `Q = 3600 × q … ÷ Cm` · 값 식 `= 값 식 = 결과 단위` - * 금액 줄: `노무비 형틀목공 = w × Q`(비목 · 이름 = 단가 × 수량) · 값 식 `= 값 × 값 = 금액`(묶음 = 노 · 재 · 경 세 줄) - * 묶음: 하나 = 머리 없음 · 둘 이상 = 「가. 이름」 머리 + 「소계」 · 맨 끝 합은 화면 맨 아래 합계 상자 - * 왼쪽 줄 누름 = 그 자리 편집기(`formula_doc_math.ts` · 부른 쪽 `mount`) · 영역 맨 아래 빈 줄 = 새 줄 - * 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 그 밖 = 그 줄 편집 - * 고친 것은 부른 쪽(`formula_doc_screen.ts`) 훅으로 — 여기선 문서를 안 고침 - * ========================================================================== */ - -import { eqText } from "@ui/ui_template_equation"; -import { editNumber, serializeEq, tidyEq } from "@ui/ui_template_equation_edit"; -import { el } from "@ui/ui_template_elements"; -import type { DocVar, FormulaDoc, MoneyLine, Node } from "../common_util/common_util_formula_doc"; -import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; -import { moneyScope } from "../common_util/common_util_formula_doc_view"; -import { - lineMap, - litsOf, - offLine, - renderNode, - symNode, - takenOf, - tryParse, - valueNode, - type Values, -} from "./formula_doc_render"; -import { ft } from "./formula_doc_text"; - -export const COSTS = ["노무비", "재료비", "경비"] as const; -const ORDER = "가나다라마바사아자차카타파하"; - -/** 줄 자리 — 식 줄 · 금액 줄 차례 */ -export interface RowRef { - kind: "line" | "money"; - i: number; -} - -export interface LinesHooks { - admin: boolean; - /** 선택으로 꺼진 금액 줄도 보임(고침 모드) */ - showOff?: boolean; - doc: FormulaDoc; - answer: DocAnswer; - vars: Map; - /** 기호 · 금액 줄 이름 → 표지 글 */ - marks: Map; - /** 편집 중인 줄 */ - editing: RowRef | null; - /** 편집기를 그 줄에 엶(글 = 선형 꼴) */ - mount: (host: HTMLElement, ref: RowRef, text: string) => void; - /** 줄 누름 — 편집 시작(`end` = 영역 맨 아래 새 줄) */ - onEditAt: (ref: RowRef | "end", at?: { x: number; y: number }) => void; - onLit: (target: "line" | "price" | "qty", i: number, at: number, value: number) => void; - onPickVar: (id: string, number?: HTMLElement) => void; - onGroupName: (old: string, now: string) => void; - onRedraw: () => void; -} - -const span = (cls: string, text?: string): HTMLSpanElement => - el("span", { className: cls, ...(text !== undefined ? { text } : {}) }); - -const memo = new WeakMap(); -/** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */ -function vals(h: LinesHooks): Values { - let got = memo.get(h.answer); - if (!got) { - got = { ...(h.answer.값 ?? {}) }; - const outs = (h.answer.줄 ?? []) as { 금액?: number | null }[]; - (h.doc.금액 ?? []).forEach((l, i) => { - if (l.기호 && typeof outs[i]?.금액 === "number") got![String(l.기호)] = outs[i].금액; - }); - memo.set(h.answer, got); - } - return got; -} - -/** 식 글 → 그림(못 풀면 글 그대로 빨강) · scope = 금액 줄이 부르는 비목 합(앞 줄 누계) */ -function expr( - text: unknown, - h: LinesHooks, - side: "sym" | "val", - inline = false, - cost?: string, - scope?: Values, -) { - const n = tryParse(text); - if (!n) { - const raw = span("fdoc__raw", String(text ?? "")); - if (String(text ?? "").trim()) raw.dataset.bad = "1"; - return { node: raw, tree: null as Node | null }; - } - const lits = side === "val" ? litsOf(n) : undefined; - return { - node: renderNode(n, { - side, - vals: scope ? { ...vals(h), ...scope } : vals(h), - vars: h.vars, - inline, - cost, - lits, - }), - tree: n, - }; -} - -const filled = (t: unknown): boolean => t !== null && t !== undefined && String(t).trim() !== ""; - -/** 금액 줄 단가 × 수량 한 식 글 */ -export const moneyBody = (l: MoneyLine): string => - filled(l.수량) ? `(${String(l.단가 ?? "")}) × (${String(l.수량)})` : String(l.단가 ?? ""); - -/** 왼쪽 칸 — 누름 = 편집 · 편집 중이면 편집기 */ -function areaCell(h: LinesHooks, ref: RowRef, edit: HTMLElement, text: () => string): HTMLElement { - const left = el("div", { - className: "fdoc__cell fdoc__sym", - attrs: { "data-row": `${ref.kind}${ref.i}` }, - children: [edit], - }); - if (!h.admin) return left; - if (h.editing && h.editing.kind === ref.kind && h.editing.i === ref.i) { - left.classList.add("is-editing"); - h.mount(edit, ref, text()); - } else - left.addEventListener("mousedown", (ev) => { - ev.preventDefault(); - h.onEditAt(ref, { x: ev.clientX, y: ev.clientY }); - }); - return left; -} - -/** 값 식 누름 — 수 = 그 자리 값 · 기호 값 = 정의 줄 · 그 밖 = 그 줄 편집 */ -function onValueClick( - ev: MouseEvent, - host: HTMLElement, - h: LinesHooks, - target: "line" | "price" | "qty", - i: number, - ref: RowRef, -): void { - if (!h.admin || host.dataset.editing) return; - const t = ev.target as HTMLElement; - const lit = t.closest("[data-lit]"); - const atom = t.closest("[data-var]"); - if (lit && host.contains(lit)) { - ev.stopPropagation(); - editNumber(lit, (text) => { - const v = Number(text.replace(/,/g, "")); - if (Number.isFinite(v)) h.onLit(target, i, Number(lit.dataset.lit), v); - else h.onRedraw(); - }); - } else if (atom && host.contains(atom)) { - ev.stopPropagation(); - h.onPickVar(atom.dataset.var!, atom.querySelector(".ui-eq__num") ?? undefined); - } else h.onEditAt(ref); -} - -function lineRow(i: number, h: LinesHooks): HTMLElement[] { - const line = (h.doc.식 ?? [])[i]; - const ref: RowRef = { kind: "line", i }; - const head = String(line.기호 ?? ""); - const edit = span("fdoc__edit"); - if (head) { - const a = span("ui-eq__var fdoc__atom fdoc__head"); - a.dataset.tok = head; - a.append(symNode(head)); - edit.append(a, span("ui-eq__op", "=")); - } - edit.append(expr(line.식, h, "sym").node); - const bad = h.marks.get(head); - if (bad) { - edit.dataset.bad = "1"; - edit.title = bad; - } - const left = areaCell(h, ref, edit, () => tidyEq(serializeEq(edit))); - const right = el("div", { - className: "fdoc__cell fdoc__valcell", - attrs: { "data-line": String(i) }, - }); - const vexpr = span("fdoc__edit"); - vexpr.append(expr(line.식, h, "val").node); - const got = vals(h)[head]; - right.append(span("ui-eq__op", "="), vexpr); - // 수 하나 · 기호 하나(탄 갈래) 줄 = 값 한 번만(「= 1 = 1」 없음) - let eff = tryParse(line.식); - for (let pick = eff ? takenOf(eff, vals(h)) : 0; eff && pick; pick = takenOf(eff, vals(h))) - eff = (eff as ["call", string, Node[]])[2][pick]; - const single = !!eff && (eff[0] === "num" || (eff[0] === "var" && vals(h)[eff[1]] === got)); - if (single) { - /* 값 식이 곧 결과 */ - } else if (got !== null && got !== undefined && typeof got !== "object") { - right.append(span("ui-eq__op", "="), valueNode(got, unitOf(head, h))); - } else right.append(span("ui-eq__op", "="), span("fdoc__dash", "—")); - if (h.admin) vexpr.addEventListener("click", (ev) => onValueClick(ev, vexpr, h, "line", i, ref)); - return [left, right]; -} - -const unitOf = (id: string, h: LinesHooks): string => - h.vars.get(id)?.단위 || (h.doc.결과 === id ? (h.doc.결과단위 ?? "") : ""); - -function moneyRow(i: number, h: LinesHooks): HTMLElement[] { - const line = (h.doc.금액 ?? [])[i]; - const ref: RowRef = { kind: "money", i }; - const got = (h.answer.줄 ?? [])[i] as - { 비목?: Record; 금액?: number | null; 까닭?: string } | undefined; - const kind = line.비목 ?? "노무비"; - const name = line.이름 ?? ""; - const body = expr(moneyBody(line), h, "sym", true).node; - const edit = span("fdoc__edit fdoc__money"); - edit.append(span("fdoc__kindtag", kind), span("fdoc__name", name), span("ui-eq__op", "="), body); - const bad = h.marks.get(name); - if (bad) { - edit.dataset.bad = "1"; - edit.title = bad; - } - const left = areaCell(h, ref, edit, () => tidyEq(`${kind} ${name} = ${serializeEq(body)}`)); - const right = el("div", { className: "fdoc__cell fdoc__valcell fdoc__money" }); - const costs = kind === "묶음" ? [...COSTS] : [null]; - const scope = moneyScope(h.answer.줄, i); - const hasQty = filled(line.수량); - for (const cost of costs) { - const row = span("fdoc__mrow"); - if (cost) row.append(span("fdoc__cost", cost), span("ui-eq__op", ":")); - const pv = span("fdoc__edit"); - pv.append(expr(line.단가, h, "val", true, cost ?? undefined, scope).node); - row.append(pv); - if (hasQty) { - const qv = span("fdoc__edit"); - qv.append(expr(line.수량, h, "val", true, undefined, scope).node); - row.append(span("ui-eq__op", "×"), qv); - if (h.admin) qv.addEventListener("click", (ev) => onValueClick(ev, qv, h, "qty", i, ref)); - } - if (h.admin) pv.addEventListener("click", (ev) => onValueClick(ev, pv, h, "price", i, ref)); - const amount = cost ? got?.비목?.[cost] : got?.금액; - row.append(span("ui-eq__op", "=")); - if (typeof amount === "number") row.append(span("ui-eq__num fdoc__amount", eqText(amount))); - else { - const dash = span("fdoc__dash", "—"); - if (got?.까닭) dash.title = got.까닭; - row.append(dash); - } - right.append(row); - } - return [left, right]; -} - -/** 이름 그 자리 고침(글만) */ -function editName(node: HTMLElement, before: string, done: (text: string) => void): void { - node.textContent = before; - node.contentEditable = "true"; - node.dataset.editing = "1"; - let over = false; - const finish = (keep: boolean): void => { - if (over) return; - over = true; - node.contentEditable = "false"; - const text = (node.textContent ?? "").trim(); - if (keep && text !== before) done(text); - else node.textContent = before || ft("Unnamed"); - delete node.dataset.editing; - }; - node.addEventListener("keydown", (ev) => { - if (ev.key === "Enter" || ev.key === "Escape") { - ev.preventDefault(); - finish(ev.key === "Enter"); - } - }); - node.addEventListener("blur", () => finish(true), { once: true }); - queueMicrotask(() => { - node.focus(); - window.getSelection()?.selectAllChildren(node); - }); -} - -/** 소계 · 계 줄(왼쪽 이름 · 가운데 노 · 재 · 경 · 계) */ -export function sumRow(label: string, b: Record | null): HTMLElement[] { - const left = el("div", { - className: "fdoc__cell fdoc__sym fdoc__total", - children: [span("fdoc__name", label)], - }); - const right = el("div", { className: "fdoc__cell fdoc__total" }); - for (const k of [...COSTS, "계"]) { - const v = b?.[k]; - right.append( - span("fdoc__tk", k), - typeof v === "number" ? span("ui-eq__num fdoc__amount", eqText(v)) : span("fdoc__dash", "—"), - ); - } - return [left, right]; -} - -/** 선택으로 꺼진 금액 줄 차례 */ -export function offLines(doc: FormulaDoc, answer: DocAnswer): Set { - const h = { doc, answer } as LinesHooks; - const steps = lineMap(doc); - return new Set((doc.금액 ?? []).flatMap((l, i) => (offLine(l, vals(h), steps) ? [i] : []))); -} - -/** 왼쪽 수식 영역 · 가운데 값 식 — 격자 칸 차례(두 칸씩) · 보이는 줄 차례(위아래 옮김) */ -export function buildLines(h: LinesHooks): { cells: HTMLElement[]; order: RowRef[] } { - const out: HTMLElement[] = []; - const order: RowRef[] = []; - const add = (cells: HTMLElement[]): void => void out.push(...cells); - (h.doc.식 ?? []).forEach((_, i) => { - add(lineRow(i, h)); - order.push({ kind: "line", i }); - }); - const lines = h.doc.금액 ?? []; - const groups: string[] = []; - lines.forEach((l) => groups.includes(l.묶음 ?? "") || groups.push(l.묶음 ?? "")); - const off = offLines(h.doc, h.answer); - const seen = (i: number): boolean => !!h.showOff || !off.has(i); - const many = groups.length > 1; - const bundles = (h.answer.묶음 ?? []) as Record[]; - const done = h.answer.셈됨; - groups.forEach((g, gi) => { - if (!lines.some((l, i) => (l.묶음 ?? "") === g && seen(i))) return; // 다 꺼진 묶음 - if (many) { - const head = span("fdoc__group", `${ORDER[gi] ?? gi + 1}. `); - const label = span("fdoc__name", g || ft("Unnamed")); - head.append(label); - if (h.admin) - label.addEventListener("click", () => editName(label, g, (t) => h.onGroupName(g, t))); - add([ - el("div", { className: "fdoc__cell fdoc__sym fdoc__grouphead", children: [head] }), - el("div"), - ]); - } - lines.forEach((l, i) => { - if ((l.묶음 ?? "") !== g || !seen(i)) return; - add(moneyRow(i, h)); - order.push({ kind: "money", i }); - }); - if (many) add(sumRow(ft("Subtotal"), done ? (bundles[gi] ?? null) : null)); - }); - if (h.admin || !out.length) { - // 영역 맨 아래 빈 줄 — 누르면 새 줄(빈 문서 = 빈 영역) - const blank = el("div", { className: "fdoc__cell fdoc__sym fdoc__blank" }); - if (!out.length) blank.classList.add("is-alone"); - if (h.admin) - blank.addEventListener("mousedown", (ev) => { - ev.preventDefault(); - h.onEditAt("end"); - }); - add([blank, el("div")]); - } - // 칸 테 — 기호 식 · 값 식 영역마다 맨 위 · 맨 아래 - for (const k of [0, 1]) { - const col = out.filter((_, n) => n % 2 === k); - col.forEach((c) => c.classList.add(k ? "fdoc__val" : "fdoc__area")); - col[0]?.classList.add("is-top"); - col[col.length - 1]?.classList.add("is-bottom"); - } - return { cells: out, order }; -} diff --git a/A00_Common/formula_doc_math.ts b/A00_Common/formula_doc_math.ts deleted file mode 100644 index 3c24927d4..000000000 --- a/A00_Common/formula_doc_math.ts +++ /dev/null @@ -1,380 +0,0 @@ -/* ============================================================================= - * 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; -} diff --git a/A00_Common/formula_doc_math_model.ts b/A00_Common/formula_doc_math_model.ts deleted file mode 100644 index 69f88fd26..000000000 --- a/A00_Common/formula_doc_math_model.ts +++ /dev/null @@ -1,259 +0,0 @@ -/* ============================================================================= - * formula_doc_math_model.ts - * 수식 영역 구조 편집 꼴(PLAN 803-2a · 요구 667-4 · 6) — 발표 자료 수식처럼 선형 입력이 바로 조판으로 · 화면 · DOM 없음 - * 한 줄 = 항목 목록 — 글자 · 분수(위 · 아래) · 괄호(안) · 아래첨자 · 위첨자(기호 이름 조각) - * 치기(`typeChar`) — `/` = 앞 피연산자를 분자로 · `_` · `^` = 첨자 칸 · `(` = 괄호(닫힘 자동) · `)` = 괄호 밖으로 - * · `*` → × · `-` → − · `<=` → ≤ · 첨자 칸에서 연산자 · 빈칸 = 첨자 밖으로 - * 선형 글 ↔ 항목 — `fromLinear`(공용 `serializeEq` 글 꼴 `(위) / (아래)`) · `toLinear`(식 글 · 첨자 `_{…}`) - * 커서 걸음 — 좌우(구조 안팎) · 위아래(분수 위 ↔ 아래) · 지움(구조 풀기) - * ========================================================================== */ - -export type Item = - | { t: "ch"; c: string } - | { t: "frac"; num: Item[]; den: Item[] } - | { t: "paren"; body: Item[] } - | { t: "sub"; body: Item[] } - | { t: "sup"; body: Item[] }; - -export interface Caret { - list: Item[]; - i: number; -} - -interface Owner { - item: Item; - /** 이 항목이 든 목록 · 자리 */ - list: Item[]; - at: number; -} - -/** 연산자 — 피연산자 경계 · 앞뒤 띄움 */ -const OPS = new Set(["+", "−", "×", "÷", "=", "==", "<", ">", "≤", "≥", "≠", ","]); -const NAME = /^[\p{L}\p{N}′.]$/u; -const RUN = /^[\p{L}\p{N}′']+$/u; -const MERGE: Record = { "<": "≤", ">": "≥", "!": "≠", "=": "==" }; - -export const isOp = (it: Item | undefined): boolean => it?.t === "ch" && OPS.has(it.c); -const ch = (c: string): Item => ({ t: "ch", c }); - -/** 항목의 칸들 */ -export function slotsOf(it: Item): Item[][] { - if (it.t === "frac") return [it.num, it.den]; - if (it.t === "ch") return []; - return [it.body]; -} - -/** 목록을 쥔 항목(맨 위 줄이면 null) */ -export function ownerOf(root: Item[], list: Item[]): Owner | null { - const go = (items: Item[]): Owner | null => { - for (let at = 0; at < items.length; at++) { - const item = items[at]; - for (const s of slotsOf(item)) { - if (s === list) return { item, list: items, at }; - const deep = go(s); - if (deep) return deep; - } - } - return null; - }; - return root === list ? null : go(root); -} - -/** 따옴표 안(글 상수) — 빈칸을 남김 */ -const inQuote = (c: Caret): boolean => - c.list.slice(0, c.i).filter((x) => x.t === "ch" && x.c === "'").length % 2 === 1; - -/** 맨 위 줄 머리(첫 = 앞) — 금액 줄 「노무비 형틀목공」 빈칸을 남김 */ -const inHead = (root: Item[], c: Caret): boolean => - c.list === root && !root.slice(0, c.i).some((x) => x.t === "ch" && x.c === "="); - -const insert = (c: Caret, it: Item): Caret => { - c.list.splice(c.i, 0, it); - return { list: c.list, i: c.i + 1 }; -}; - -/** 구조 밖 바로 뒤로 */ -function out(root: Item[], c: Caret): Caret { - const o = ownerOf(root, c.list); - return o ? { list: o.list, i: o.at + 1 } : c; -} - -/** 글자 하나 치기 — `linear` = 선형 글 풀이(분모는 연산자에서 끝남) */ -export function typeChar(root: Item[], c: Caret, raw: string, linear = false): Caret { - const k = raw === "*" ? "×" : raw === "-" ? "−" : raw; - const o = ownerOf(root, c.list); - const script = o && (o.item.t === "sub" || o.item.t === "sup"); - if (/\s/.test(k)) { - if (inQuote(c) || inHead(root, c)) return insert(c, ch(" ")); - if (script) return out(root, c); - if (!linear && o?.item.t === "frac") return out(root, c); - return c; - } - if (inQuote(c) && k !== "'") return insert(c, ch(k)); - if (script && !(NAME.test(k) && k !== ".")) return typeChar(root, out(root, c), k, linear); - if (k === "_" || k === "^") { - const prev = c.list[c.i - 1]; - const ok = - (prev?.t === "ch" && NAME.test(prev.c) && prev.c !== ".") || (k === "^" && prev?.t === "sub"); - if (!ok) return c; - const s: { t: "sub" | "sup"; body: Item[] } = { t: k === "_" ? "sub" : "sup", body: [] }; - insert(c, s); - return { list: s.body, i: 0 }; - } - if (k === "(") { - const p: Item = { t: "paren", body: [] }; - insert(c, p); - return { list: p.body, i: 0 }; - } - if (k === ")") { - for (let at = c.list, w = ownerOf(root, at); w; w = ownerOf(root, (at = w.list))) - if (w.item.t === "paren") return { list: w.list, i: w.at + 1 }; - return c; - } - if (k === "/") { - let start = c.i; - while (start > 0 && !isOp(c.list[start - 1])) start--; - const pulled = c.list.splice(start, c.i - start); - const num = pulled.length === 1 && pulled[0].t === "paren" ? pulled[0].body : pulled; - const f: Item = { t: "frac", num, den: [] }; - c.list.splice(start, 0, f); - return num.length ? { list: f.den, i: 0 } : { list: f.num, i: 0 }; - } - if (OPS.has(k) || k === "!") { - // 선형 풀이 — 분모는 연산자에서 끝남(`(a) / (b) × c`) - if (linear && o?.item.t === "frac" && c.list === o.item.den && c.list.length) - return typeChar(root, out(root, c), k, linear); - const prev = c.list[c.i - 1]; - if (k === "=" && prev?.t === "ch" && MERGE[prev.c]) { - c.list[c.i - 1] = ch(MERGE[prev.c]); - return c; - } - } - return insert(c, ch(k)); -} - -/** 분수 칸이 괄호 하나뿐이면 괄호를 벗김(`(a) / (b)` 꼴) */ -function unwrap(items: Item[]): void { - for (const it of items) { - if (it.t === "frac") { - for (const key of ["num", "den"] as const) { - const s = it[key]; - if (s.length === 1 && s[0].t === "paren") it[key] = s[0].body; - } - } - slotsOf(it).forEach(unwrap); - } -} - -/** 선형 글 → 항목(공용 `serializeEq` 글 · 붙여넣기) */ -export function fromLinear(text: string, root: Item[] = [], at?: Caret): Caret { - let c = at ?? { list: root, i: root.length }; - const chars = [...text]; - for (let n = 0; n < chars.length; n++) { - const k = chars[n]; - // 첨자 묶음 `_{a b}` — 닫는 } 까지 그대로 첨자 칸에 - if ((k === "_" || k === "^") && chars[n + 1] === "{") { - const end = chars.indexOf("}", n + 2); - if (end > 0) { - const was = c; - c = typeChar(root, c, k, true); - if (c !== was) { - chars.slice(n + 2, end).forEach((x) => (c = insert(c, ch(x)))); - c = out(root, c); - } - n = end; - continue; - } - } - c = typeChar(root, c, k, true); - } - unwrap(root); - return c; -} - -/** 항목 → 식 글 */ -export function toLinear(items: Item[]): string { - const go = (list: Item[]): string => - list - .map((it) => { - if (it.t === "ch") return it.c === "," ? ", " : OPS.has(it.c) ? ` ${it.c} ` : it.c; - if (it.t === "frac") return `(${go(it.num)}) / (${go(it.den)})`; - if (it.t === "paren") return `(${go(it.body)})`; - const body = go(it.body).trim(); - if (!body) return ""; - return `${it.t === "sub" ? "_" : "^"}${RUN.test(body) ? body : `{${body}}`}`; - }) - .join(""); - return go(items).replace(/\s+/g, " ").trim(); -} - -// ── 커서 걸음 ────────────────────────────────────────────────────────── - -export function right(root: Item[], c: Caret): Caret { - if (c.i < c.list.length) { - const s = slotsOf(c.list[c.i]); - return s.length ? { list: s[0], i: 0 } : { list: c.list, i: c.i + 1 }; - } - const o = ownerOf(root, c.list); - if (!o) return c; - const s = slotsOf(o.item); - const k = s.indexOf(c.list); - return k < s.length - 1 ? { list: s[k + 1], i: 0 } : { list: o.list, i: o.at + 1 }; -} - -export function left(root: Item[], c: Caret): Caret { - if (c.i > 0) { - const s = slotsOf(c.list[c.i - 1]); - const last = s[s.length - 1]; - return last ? { list: last, i: last.length } : { list: c.list, i: c.i - 1 }; - } - const o = ownerOf(root, c.list); - if (!o) return c; - const s = slotsOf(o.item); - const k = s.indexOf(c.list); - return k > 0 ? { list: s[k - 1], i: s[k - 1].length } : { list: o.list, i: o.at }; -} - -/** 분수 위 ↔ 아래(분수 밖이면 null — 줄을 옮김) */ -export function vertical(root: Item[], c: Caret, up: boolean): Caret | null { - for (let at = c.list, o = ownerOf(root, at); o; o = ownerOf(root, (at = o.list))) { - if (o.item.t !== "frac") continue; - const from = at === o.item.num ? "num" : "den"; - if ((up && from === "num") || (!up && from === "den")) continue; - const to = up ? o.item.num : o.item.den; - return { list: to, i: Math.min(c.i, to.length) }; - } - return null; -} - -/** 지움(뒤로) — 글자 지움 · 구조 앞이면 구조 안 끝으로 · 칸 맨 앞이면 구조를 풂 · 빈 줄 맨 앞 = null */ -export function backspace(root: Item[], c: Caret): Caret | null { - if (c.i > 0) { - const prev = c.list[c.i - 1]; - if (prev.t === "ch") { - c.list.splice(c.i - 1, 1); - return { list: c.list, i: c.i - 1 }; - } - return left(root, c); - } - const o = ownerOf(root, c.list); - if (!o) return root.length ? c : null; - const parts = slotsOf(o.item); - const flat: Item[] = - o.item.t === "frac" && o.item.den.length && o.item.num.length - ? [...o.item.num, ch("÷"), ...o.item.den] - : parts.flat(); - o.list.splice(o.at, 1, ...flat); - const before = parts.indexOf(c.list) > 0 ? parts[0].length : 0; - return { list: o.list, i: o.at + before }; -} - -/** 지움(앞으로) — 글자 지움 · 구조면 안으로 */ -export function del(root: Item[], c: Caret): Caret { - if (c.i >= c.list.length) return c; - if (c.list[c.i].t === "ch") { - c.list.splice(c.i, 1); - return c; - } - return right(root, c); -} diff --git a/A00_Common/formula_doc_render.ts b/A00_Common/formula_doc_render.ts index 1b87d2fd6..c9c3d7a72 100644 --- a/A00_Common/formula_doc_render.ts +++ b/A00_Common/formula_doc_render.ts @@ -1,267 +1,25 @@ /* ============================================================================= * formula_doc_render.ts - * 수식 문서 그림(PLAN 802-3 · 요구 660~664) — 식 나무 → 조판 수식(분수 · 첨자 · 괄호) · 화면 공용(M01 · M02) - * 기호 식(side "sym") = 기호만(한글 이름 · 표 칸 글 · DB 키 없음) · 값 식(side "val") = 기호 자리에 값 + 단위 - * 만약 = 탄 갈래만(값을 알 때) · 반올림 · 올림 · 버림 자리 감싸개 숨김(662-4) — 숨긴 글은 `data-pre` · `data-post` - * 고침 글 꼴 = 공용 `serializeEq`(원자 `data-tok` · 분수 · 괄호 · 단위 뺌) — 보이는 그림이 곧 고침 자리 - * evalNode = 그림용 값(탄 갈래 · 값 식) — 셈 정본은 계산기(`common_util_formula_doc_calc.ts`) - * reach = 지금 탄 갈래로 닿는 기호(정의 영역에 보일 것) + * 수식 문서 닿는 기호(PLAN 802-3 · 804-6) — 줄 쌓기 산출근거(`line_stack_out.ts`)가 씀 + * reach = 지금 탄 갈래로 닿는 기호(만약 안 탄 갈래 · 숨김 자리 인자 빼고) + * 옛 수식 문서 탭의 조판 그림(renderNode · 값 식 · 수 바꿈)은 804-6 에 걷음 * ========================================================================== */ -import { eqText, symbolOf } from "@ui/ui_template_equation"; import { linkNames, - splitId, - textOf, varId, - type DocVar, type FormulaDoc, type Node, } from "../common_util/common_util_formula_doc"; -import { isNum } from "../common_util/common_util_formula_doc_calc"; import { - leafNum, offLine, takenOf, tryParse, type Values, } from "../common_util/common_util_formula_doc_view"; -export { - evalNode, - leafNum, - lineMap, - offLine, - offZero, - takenOf, - tryParse, - type Values, -} from "../common_util/common_util_formula_doc_view"; -const HIDE = new Set(["반올림", "올림", "버림"]); -const SHOW: Record = { - "*": "×", - "/": "÷", - "-": "−", - "<=": "≤", - ">=": "≥", - "!=": "≠", -}; -const PREC: Record = { - 또는: 1, - 그리고: 2, - "<": 3, - "<=": 3, - ">": 3, - ">=": 3, - "==": 3, - "!=": 3, - "+": 4, - "-": 4, - "*": 5, - "/": 5, -}; -const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9); - -export interface Ctx { - side: "sym" | "val"; - vals: Values; - vars: Map; - /** ÷ 를 분수 대신 한 줄로(금액 줄 · 경쟁사 꼴) */ - inline?: boolean; - /** 묶음 값의 비목(금액 묶음 줄 노 · 재 · 경 줄) */ - cost?: string; - /** 수 마디 → 차례(값 식 숫자 고침) */ - lits?: Map; -} - -const span = (cls: string, text?: string): HTMLSpanElement => { - const s = document.createElement("span"); - s.className = cls; - if (text !== undefined) s.textContent = text; - return s; -}; - -/** 기호 글(`Q_시공^표`) → 기호 그림(첨자) */ -export function symNode(id: string): HTMLElement { - const [기호, 아래, 위] = splitId(id); - return symbolOf({ 기호, 아래, 위, 열쇠: id }); -} - -/** 값 글 + 단위 */ -export function valueNode(value: unknown, unit = ""): HTMLElement { - const box = span("fdoc__val"); - box.append(span("ui-eq__num", eqText(value))); - if (unit) box.append(span("ui-eq__unit", ` ${unit}`)); - return box; -} - -const paren = (inner: HTMLElement): HTMLElement => { - const p = span("ui-eq__paren"); - p.append(span("ui-eq__inner")); - p.firstElementChild!.append(inner); - return p; -}; - -const op = (o: string): HTMLElement => span("ui-eq__op", SHOW[o] ?? o); - -/** 감쌈 — 그림엔 안 보이는 앞 · 뒤 글(고침 때 그대로 되돌림) */ -const hidden = (pre: string, post: string, inner: HTMLElement): HTMLElement => { - const w = span("fdoc__wrap"); - w.dataset.pre = pre; - w.dataset.post = post; - w.append(inner); - return w; -}; - -function varNode(id: string, c: Ctx): HTMLElement { - if (c.side === "sym") { - const a = span("ui-eq__var fdoc__atom"); - a.dataset.tok = id; - a.dataset.var = id; - a.append(symNode(id)); - return a; - } - let v = c.vals[id]; - if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost]; - const a = span("ui-eq__var fdoc__atom is-val"); - a.dataset.tok = id; - a.dataset.var = id; - if (v === null || v === undefined || typeof v === "object") { - a.classList.add("is-missing"); // 값 없음 · 정의 필요 — 기호 그대로(빨강) - a.append(symNode(id)); - } else a.append(valueNode(v, c.vars.get(id)?.단위 ?? "")); - return a; -} - -/** 나무 → 조판 수식 */ -export function renderNode(n: Node, c: Ctx): HTMLElement { - const go = (x: Node): HTMLElement => renderNode(x, c); - switch (n[0]) { - case "num": { - const s = span("ui-eq__num fdoc__lit", eqText(n[1])); - const at = c.lits?.get(n); - if (at !== undefined) s.dataset.lit = String(at); - return s; - } - case "str": { - const s = span("ui-eq__str fdoc__atom", n[1]); - s.dataset.tok = `'${n[1]}'`; - return s; - } - case "var": - return varNode(n[1], c); - case "field": { - if (c.side === "val" && n[1][0] === "var") { - const v = c.vals[n[1][1]]; - const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined; - if (isNum(got)) { - const a = span("ui-eq__var fdoc__atom is-val"); - a.dataset.tok = textOf(n); - a.dataset.var = n[1][1]; - a.append(valueNode(got)); - return a; - } - } - const a = span("ui-eq__var fdoc__atom"); - a.dataset.tok = textOf(n); - if (n[1][0] === "var") a.dataset.var = n[1][1]; - const base = n[1][0] === "var" ? symNode(n[1][1]) : span("", textOf(n[1])); - a.append(base, span("ui-eq__script", `.${n[2]}`)); - return a; - } - case "neg": { - const s = span("ui-eq__body"); - s.append(op("-"), n[1][0] === "bin" ? paren(go(n[1])) : go(n[1])); - return s; - } - case "pct": { - const s = span("ui-eq__body"); - s.append(prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1]), span("ui-eq__op", "%")); - return s; - } - case "call": { - const [, name, items] = n; - if (name === "만약") { - const pick = takenOf(n, c.vals); - if (pick) { - const [t, a, b] = items.map(textOf); - return pick === 1 - ? hidden(`만약(${t}, `, `, ${b})`, go(items[1])) - : hidden(`만약(${t}, ${a}, `, ")", go(items[2])); - } - } - if (HIDE.has(name) && items.length >= 1 && items.length <= 2) { - const post = items.length === 2 ? `, ${textOf(items[1])})` : ")"; - return hidden(`${name}(`, post, go(items[0])); - } - const s = span("ui-eq__body"); - const inner = span("ui-eq__body"); - items.forEach((x, i) => { - if (i) inner.append(span("ui-eq__op", ",")); - inner.append(go(x)); - }); - s.append(span("ui-eq__fn", name), paren(inner)); - return s; - } - case "bin": { - const [, o, a, b] = n; - // 선택으로 꺼진 항(만약이 0 · 1 을 탐) = 안 보임(663-1) · 고침 글은 그대로 - const na = leafNum(a, c.vals); - const nb = leafNum(b, c.vals); - if ((o === "+" || o === "-") && nb === 0) return hidden("(", `) ${o} ${textOf(b)}`, go(a)); - if ((o === "*" || o === "/") && nb === 1) return hidden("(", `) ${o} ${textOf(b)}`, go(a)); - if ((o === "+" && na === 0) || (o === "*" && na === 1)) - return hidden(`${textOf(a)} ${o} (`, ")", go(b)); - if (o === "/" && !c.inline) { - const f = span("ui-eq__frac"); - const top = span("ui-eq__top"); - const low = span("ui-eq__bottom"); - top.append(go(a)); - low.append(go(b)); - f.append(top, low); - return f; - } - const mine = PREC[o]; - const s = span("ui-eq__body"); - const left = go(a); - const right = go(b); - s.append( - prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(left) : left, - op(o), - prec(b) <= mine ? paren(right) : right, - ); - return s; - } - } -} - -/** 수 마디 차례(나무 걸음 차례) */ -export function litsOf(n: Node): Map { - const out = new Map(); - const go = (x: Node): void => { - if (x[0] === "num") out.set(x, out.size); - if (x[0] === "bin") { - go(x[2]); - go(x[3]); - } else if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") go(x[1]); - else if (x[0] === "call") x[2].forEach(go); - }; - go(n); - return out; -} - -/** 차례 번째 수를 바꾼 식 글 */ -export function swapLit(text: string, at: number, now: number): string { - const n = tryParse(text); - if (!n) return text; - const lits = litsOf(n); - for (const [node, i] of lits) if (i === at) node[1] = now; - for (const [node] of lits) (node as [string, number, string])[2] = String(node[1]); - return textOf(n); -} - /** 탄 갈래로 부르는 이름(만약 안 탄 갈래 · 숨김 자리 인자 빼고) */ -export function namesTaken(n: Node, vals: Values, out: string[] = []): string[] { +function namesTaken(n: Node, vals: Values, out: string[] = []): string[] { const add = (x: string): void => { if (!out.includes(x)) out.push(x); }; diff --git a/A00_Common/formula_doc_rows.ts b/A00_Common/formula_doc_rows.ts deleted file mode 100644 index 3fcbcd3a0..000000000 --- a/A00_Common/formula_doc_rows.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* ============================================================================= - * formula_doc_rows.ts - * 수식 영역 줄 편집 흐름(PLAN 803-2a · 요구 667-4 · 6) — 어느 줄을 편집 중인지 · 줄 사이 옮김 · 새 줄 · 문서에 넣기 - * 줄 누름 = 편집 중 글을 넣고 그 줄을 엶 · Enter = 아래 새 줄(금액 줄 뒤면 같은 비목 「노무비 」로 시작) · - * ↑ ↓ = 옆 줄(가로 자리 그대로) · 빈 줄 맨 앞 지움 = 줄 걷고 윗줄 끝 · Esc · 밖 누름 = 나옴 · 빈 줄은 나올 때 걷음 - * 글 → 문서 = `editRow`(식 줄 · 금액 줄 「노무비 형틀목공 = w × Q」 · 갈래가 바뀌면 옮김) - * 그림 · 셈은 부른 쪽(`formula_doc_screen.ts` — `change` · `draw`) · 편집기는 `formula_doc_math.ts` - * ========================================================================== */ - -import type { FormulaDoc, MoneyLine } from "../common_util/common_util_formula_doc"; -import { editRow, splitLine, sweep } from "./formula_doc_edit"; -import type { RowRef } from "./formula_doc_lines"; -import { openMath, type Leave, type MathEditor, type MathHooks } from "./formula_doc_math"; -import { tryParse } from "./formula_doc_render"; -import { ft } from "./formula_doc_text"; - -interface Editing { - ref: RowRef; - at?: MathHooks["at"]; - /** 처음 글(새 금액 줄 「노무비 」) */ - seed?: string; -} -interface Current { - ref: RowRef; - first: string; - ed: MathEditor | null; -} - -export interface RowsHost { - doc: () => FormulaDoc | null; - /** 문서 고침 → 캐시 · 셈 · 그림 */ - change: (fn: (d: FormulaDoc) => void) => void; - /** 고침 없이 캐시 · 셈(그림 없음) */ - settle: () => void; - draw: () => void; - root: HTMLElement; -} - -export function createRows(host: RowsHost) { - let editing: Editing | null = null; - let current: Current | null = null; - let order: RowRef[] = []; - let live: { ref: RowRef; text: string; first: string } | null = null; // 다시 그림 사이 편집 중 글 - - const doc = (): FormulaDoc | null => host.doc(); - const rowObj = (r: RowRef): object | undefined => - (r.kind === "line" ? doc()?.식 : doc()?.금액)?.[r.i]; - const refOf = (o: object | null | undefined): RowRef | null => { - const a = (doc()?.식 ?? []).indexOf(o as never); - if (a >= 0) return { kind: "line", i: a }; - const b = (doc()?.금액 ?? []).indexOf(o as never); - return b >= 0 ? { kind: "money", i: b } : null; - }; - /** 편집 중 글을 문서에 — 돌려줌 = 그 줄 객체 */ - const commit = (d: FormulaDoc, c: Current, text: string): object | null => - text === c.first ? (rowObj(c.ref) ?? null) : editRow(d, c.ref.kind, c.ref.i, text); - /** 새 줄 — `after` 뒤(식 줄 = 식 줄 · 금액 줄 = 같은 비목 · 묶음) · null = 영역 맨 끝 */ - const newRow = (d: FormulaDoc, after: object | null): { obj: object; seed: string } => { - const lines = (d.식 ??= []); - const money = (d.금액 ??= []); - const li = after ? lines.indexOf(after as never) : -1; - const mi = after ? money.indexOf(after as MoneyLine) : -1; - const base = mi >= 0 ? money[mi] : li < 0 ? money[money.length - 1] : undefined; - if (!base) { - const o = { 기호: "", 식: "" }; - lines.splice(li >= 0 ? li + 1 : lines.length, 0, o); - return { obj: o, seed: "" }; - } - const kind = base.비목 ?? "노무비"; - const o: MoneyLine = { - 이름: "", - 비목: kind, - 단가: "", - ...(base.묶음 ? { 묶음: base.묶음 } : {}), - }; - money.splice(mi >= 0 ? mi + 1 : money.length, 0, o); - return { obj: o, seed: `${kind} ` }; - }; - const target = (obj: object | null, at?: MathHooks["at"], seed?: string): void => { - const ref = refOf(obj); - editing = ref ? { ref, at, ...(seed ? { seed } : {}) } : null; - }; - const take = (): Current | null => { - const c = current; - current = live = null; - c?.ed?.close(); - return c; - }; - - /** 줄 누름 — 편집 중 글을 넣고 그 줄(또는 맨 끝 새 줄)을 엶 */ - const editAt = (ref: RowRef | "end", at?: { x: number; y: number }): void => { - const c = take(); - const want = ref === "end" ? null : (rowObj(ref) ?? null); - host.change((d) => { - if (c) commit(d, c, c.ed?.text() ?? c.first); - const made = ref === "end" ? newRow(d, null) : null; - sweep(d, made?.obj ?? want); - target(made?.obj ?? want, at ?? "end", made?.seed); - }); - }; - - /** 편집기에서 나옴 — Enter = 새 줄 · ↑ ↓ = 옆 줄 · 지움 = 빈 줄 걷고 윗줄 끝 */ - const finish = (c: Current, text: string, how: Leave, x?: number): void => { - if (current === c) current = null; - live = null; - const at = order.findIndex((r) => r.kind === c.ref.kind && r.i === c.ref.i); - const near = - how === "down" ? order[at + 1] : how === "up" || how === "back" ? order[at - 1] : undefined; - const nearObj = near ? (rowObj(near) ?? null) : null; - const d = doc(); - if (how === "exit" && text === c.first && d) { - editing = null; - const before = JSON.stringify(d); - sweep(d); - if (JSON.stringify(d) !== before) host.settle(); - // 값 식 숫자 등 다른 자리를 고치는 중이면 그대로(다음 그림에서 정리) - if (!host.root.querySelector("[data-editing]:not(.fdoc-math)")) host.draw(); - return; - } - host.change((dd) => { - const mine = commit(dd, c, text); - const made = how === "enter" ? newRow(dd, mine) : null; - sweep(dd, made?.obj ?? nearObj); - const pos: MathHooks["at"] = - how === "up" || how === "down" ? { x: x ?? 0 } : how === "back" ? "end" : "start"; - target(made?.obj ?? nearObj, pos, made?.seed); - }); - }; - - return { - editing: (): RowRef | null => editing?.ref ?? null, - editingMoney: (): boolean => editing?.ref.kind === "money", - setOrder: (o: RowRef[]): void => void (order = o), - editAt, - /** 다시 그리기 앞 — 편집 중 글을 기억(같은 줄을 다시 열 때 이어 씀) */ - beforeDraw: (): void => { - const c = current; - if (!c?.ed) return; - live = { ref: c.ref, text: c.ed.text(), first: c.first }; - c.ed.close(); - current = null; - }, - /** 편집 중 글을 문서에 넣고 닫음(그림 없음 · 저장 · 나가기 앞) */ - flush: (): void => { - const c = take(); - editing = null; - const d = doc(); - if (!c || !d) return; - commit(d, c, c.ed?.text() ?? c.first); - sweep(d); - host.settle(); - }, - /** 편집 버림(문서 바뀜 · 고침 버림) */ - reset: (): void => { - take(); - editing = null; - }, - mount: (el: HTMLElement, ref: RowRef, text: string): void => { - const e = editing; - if (!e) return; - const again = live && live.ref.kind === ref.kind && live.ref.i === ref.i ? live : null; - live = null; - const first = again?.first ?? e.seed ?? text; - const c: Current = { ref, first, ed: null }; - current = c; - queueMicrotask(() => { - if (current !== c || !el.isConnected) return; - c.ed = openMath(el, { - text: again?.text ?? first, - at: again ? "end" : (e.at ?? "end"), - onInput: (t) => { - const body = splitLine(t)[1]; - el.toggleAttribute("data-bad", !!body.trim() && !tryParse(body)); - }, - onLeave: (t, how, x) => finish(c, t, how, x), - }); - }); - }, - /** 편집 중 금액 줄부터 그 묶음 끝까지 새 묶음(이름은 머리를 눌러 고침) */ - splitGroup: (): void => { - const c = current; - if (!c || c.ref.kind !== "money") return; - take(); - host.change((d) => { - const mine = commit(d, c, c.ed?.text() ?? c.first) as MoneyLine | null; - const money = d.금액 ?? []; - const at = mine ? money.indexOf(mine) : -1; - if (at < 0) return void (editing = null); - const old = mine!.묶음 ?? ""; - const taken = new Set(money.map((l) => l.묶음 ?? "")); - let name = ft("New_Group"); - for (let n = 2; taken.has(name); n++) name = `${ft("New_Group")} ${n}`; - for (let k = at; k < money.length && (money[k].묶음 ?? "") === old; k++) - money[k].묶음 = name; - target(mine, "end"); - }); - }, - }; -} diff --git a/A00_Common/formula_doc_screen.ts b/A00_Common/formula_doc_screen.ts deleted file mode 100644 index 1b398e9b3..000000000 --- a/A00_Common/formula_doc_screen.ts +++ /dev/null @@ -1,667 +0,0 @@ -/* ============================================================================= - * formula_doc_screen.ts - * 단가산출 기초 수식 문서 화면(PLAN 802-3 · 803-2a · 요구 660~667 · 요구서_664 3 장) — 공용(M01 · M02 = 길 앞머리만 바꿈) - * 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · [고침 버림] · [수식 저장](관리자) — 설명 글 없음 - * 가운데 = 세 영역 가로 — 기호 식(하나의 수식 영역 · 줄바꿈) | 값 식(같은 높이) | 기호 정의(`formula_doc_defs.ts`) - * 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 단가산출 상세 탭 부품 · `formula_doc_foot.ts`) - * 기호 식 줄 누름 = 그 자리 편집기(`formula_doc_math.ts`) · 편집 중엔 영역 위 수식 도구 줄 · 빈 문서 = 세 영역 빈 화면 - * 빈 문서에서 시작 — 영역을 눌러 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름 - * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 연결 값은 서버 기록 캐시(`common_util_formula_doc_cache.ts`) - * · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈 - * 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀) - * 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」 - * M02 사본(`opt.copy` · 802-4b-2) — 길 앞머리 = SK 마다(`/m02/layers/{층}/sangeun/{키}/doc`) · 옵션 = 문서 값(SK 고른 값 · 저장) · - * [단가 갱신] · [재생성] · 「M01 새 판 있음」(`formula_doc_copy.ts`) · 단가산출 연결 끊기 · 다시 잇기(`formula_doc_sub.ts`) · - * 저장 몸 {문서, 끊음, 판} · `admin` = 층 쓰기 권한(없으면 읽기만 · 옵션도 잠금) - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { createBadge } from "@ui/ui_template_badge"; -import { createButton } from "@ui/ui_template_button"; -import { editNumber } from "@ui/ui_template_equation_edit"; -import { - el, - hideLoadingOverlay, - showConfirmDialog, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { createInputField } from "@ui/ui_template_input"; -import { - DocError, - blankDoc, - rename, - varId, - type FormulaDoc, - type Link, -} from "../common_util/common_util_formula_doc"; -import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; -import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache"; -import type { DocTable } from "../common_util/common_util_formula_doc_table"; -import { - askJob, - copyBadges, - copyTools, - type CopyJob, - type CopyMark, - type CopyOptions, - type JobReply, -} from "./formula_doc_copy"; -import { buildDefs, type Pick } from "./formula_doc_defs"; -import { editLit, ensureVar, splitLine, sweep } from "./formula_doc_edit"; -import { docFoot } from "./formula_doc_foot"; -import { buildLines, offLines } from "./formula_doc_lines"; -import { mathToolbar } from "./formula_doc_math"; -import { buildOptions } from "./formula_doc_options"; -import { reach } from "./formula_doc_render"; -import { createRows } from "./formula_doc_rows"; -import { subBlock } from "./formula_doc_sub"; -import { ft } from "./formula_doc_text"; - -export { splitLine }; -import "./formula_doc.css"; - -export interface DocScreenOptions { - /** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links · GET /table */ - base: string; - admin: boolean; - /** sessionStorage 열쇠 */ - cache: string; - /** M02 사본(SK) — 길 앞머리 · 판 · 끊음 · 할일 */ - copy?: CopyOptions; -} - -type Docs = Record; - -interface Loaded { - 문서: FormulaDoc; - 셈: DocAnswer; - 연결: DocRecord; - 새?: boolean; - 부름?: Docs; - 끊음?: Docs; - 사본?: CopyMark; - 판?: string; -} - -const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; - -export function createDocScreen(opt: DocScreenOptions) { - const head = el("div", { className: "fdoc__head" }); - const grid = el("div", { className: "fdoc__grid" }); - const side = el("div", { className: "fdoc__side" }); - const foot = el("div", { className: "fdoc__footslot" }); - const root = el("div", { - className: "fdoc ui-eq", - attrs: { "data-formula-doc": "" }, - children: [head, el("div", { className: "fdoc__areas", children: [grid, side] }), foot], - }); - let key = ""; - let saved = ""; - let doc: FormulaDoc | null = null; - let answer: DocAnswer | null = null; - let cache = createDocCache(); - let given: Record = {}; - let openDef: string | null = null; - let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드) - let seq = 0; - let asked = ""; - let ctl: AbortController | null = null; - const tables = new Map>(); - const rows = createRows({ - doc: () => doc, - change: (fn) => change(fn), - settle: () => { - keep(); - recalc(); - }, - draw: () => draw(), - root, - }); - const cp = opt.copy; - let cut: Docs = {}; // 사본 — 끊은 단가산출 문서(이 사본 안 · 고칠 수 있음) - let savedCut = "{}"; - let calls: Docs = {}; // 사본 — 부름 문서(다시 잇기 = 이 문서로) - let mark: CopyMark | undefined; - let version = ""; - - const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { - const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, { - method: body === undefined ? "GET" : "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - signal, - }); - const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; - if (!r.ok) { - const why = data.detail; - throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), { - status: r.status, - }); - } - return data; - }; - - // ── 캐시(sessionStorage) ── - const drafts = (): Record => { - try { - return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record; - } catch { - return {}; - } - }; - const dirty = (): boolean => - !!doc && (JSON.stringify(doc) !== saved || JSON.stringify(cut) !== savedCut); - const cutKey = (): string => `${key}#끊음`; - const keep = (): void => { - const all = drafts(); - if (dirty()) all[key] = doc!; - else delete all[key]; - if (cp && dirty()) all[cutKey()] = cut as unknown as FormulaDoc; - else delete all[cutKey()]; - try { - sessionStorage.setItem(opt.cache, JSON.stringify(all)); - } catch { - /* 캐시가 막혀도 화면 안 고침은 남음 */ - } - }; - - // ── 셈 ── - const usable = (): Record => { - const out: Record = {}; - for (const v of doc?.변수 ?? []) { - const k = v.열쇠; - if (!k || !(k in given)) continue; - const kind = v.연결?.갈래; - if (kind === "입력") out[k] = given[k]; - if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k]))) - out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k])); - } - return out; - }; - const recalc = (): void => { - if (!doc) return; - answer = calcDoc(doc, cache.links, usable()); - if (cache.takeMissing()) ask(); - }; - /** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함(같은 문서 · 입력은 한 번만) */ - const ask = (): void => { - const snap = JSON.stringify([doc, usable()]); - if (snap === asked) return; - asked = snap; - ctl?.abort(); - ctl = new AbortController(); - const mine = ++seq; - void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>( - "/preview", - cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() }, - ctl.signal, - ) - .then((got) => { - if (mine !== seq) return; - cache.merge(got.연결); - calls = { ...calls, ...(got.부름 ?? {}) }; - cache.merge({ 문서: cut }); - recalc(); - draw(); - }) - .catch(() => undefined); - }; - - const change = (fn: (d: FormulaDoc) => void): void => { - if (!doc) return; - fn(doc); - keep(); - recalc(); - draw(); - }; - - const onPickVar = (id: string, num?: HTMLElement): void => { - const v = doc?.변수?.find((x) => varId(x) === id); - if (opt.admin && num && v?.연결?.갈래 === "숫자") { - editNumber(num, (t) => { - const n = Number(t.replace(/,/g, "")); - change((d) => (ensureVar(d, id).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t })); - }); - return; - } - openDef = id; - draw(); - grid.querySelector(`[data-def="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" }); - }; - - const tableOf = (k: string): Promise => { - if (!tables.has(k)) - tables.set( - k, - call(`/table?key=${encodeURIComponent(k)}`) - .then((t) => { - if (t.끊김) return null; - cache.putTable(k, t); - return t; - }) - .catch(() => null), - ); - return tables.get(k)!; - }; - - const subDoc = (k: string): FormulaDoc | null => { - try { - return cache.links.doc(k) ?? null; - } catch { - cache.takeMissing(); - return null; - } - }; - - // ── 사본 — 단가산출 연결 끊기 · 다시 잇기 · 끊은 문서 고침 ── - const changeCut = (k: string, fn: (d: FormulaDoc) => void): void => { - if (!cut[k]) return; - fn(cut[k]); - cache.merge({ 문서: { [k]: cut[k] } }); - keep(); - recalc(); - draw(); - }; - const onCut = (k: string): void => { - const from = subDoc(k); - if (!from) return; - cut = { ...cut, [k]: clone(from) }; - changeCut(k, () => undefined); - }; - const onJoin = async (k: string): Promise => { - if (!(await showConfirmDialog(ft("Ask_Relink"), ft("Relink")))) return; - const { [k]: _gone, ...rest } = cut; - cut = rest; - if (calls[k]) cache.merge({ 문서: { [k]: calls[k] } }); - else asked = ""; // 부름 문서를 아직 모름 — 뒤에서 서버에 물음 - keep(); - recalc(); - draw(); - }; - const sub = (id: string): HTMLElement | null => - subBlock(id, { - doc, - answer, - cache, - ...(cp - ? { - copy: { - edit: opt.admin, - has: (k: string) => k in calls || k in cut, - cut: (k: string) => cut[k], - onCut, - onJoin: (k: string) => void onJoin(k), - change: changeCut, - }, - } - : {}), - }); - - // ── 그림 ── - /** 옵션 가로 — M01 = 화면 값만(`given`) · 사본 = 문서 값(SK 고른 값 · 저장) */ - const optionBar = (): HTMLElement[] => - buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => { - const at = doc?.변수?.indexOf(v) ?? -1; // 옵션 열쇠 ≠ 기호 — 그 정의 자리에 값 - if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x })); - given[k] = x; - recalc(); - draw(); - }); - - /** 위 도구 줄 — 결과 기호(금액 줄 없을 때) · 고침 표 · 사본 표 · [꺼진 줄] · [고침 버림] · [수식 저장] */ - const toolRow = (edit: boolean): HTMLElement => { - const bits: HTMLElement[] = []; - if (edit && doc && !(doc.금액 ?? []).length) { - const f = createInputField({ - placeholder: ft("Result_Sym"), - value: doc.결과 ?? "", - compact: true, - size: 24, - }); - f.input.addEventListener("change", () => - change((d) => { - const t = f.input.value.trim(); - if (t) d.결과 = t; - else delete d.결과; - }), - ); - f.root.classList.add("fdoc__resultsym"); - bits.push(f.root); - } - if (dirty()) bits.push(createBadge(ft("Dirty"), "warning")); - if (cp) bits.push(...copyBadges(mark, opt.admin)); - const off = doc && answer ? offLines(doc, answer).size : 0; - const busy = dirty() || !!rows.editing(); - // 편집 중 누름 — 편집기 커서를 안 뺏음(저장 · 버림이 편집 중 글을 먼저 받음) - const hold = (b: HTMLButtonElement): HTMLButtonElement => { - b.addEventListener("mousedown", (ev) => ev.preventDefault()); - return b; - }; - const tools = edit - ? [ - ...(off - ? [ - createButton({ - label: `${showOff ? ft("Hide_Off") : ft("Show_Off")} ${off}`, - variant: "ghost", - size: 28, - onClick: () => { - showOff = !showOff; - draw(); - }, - }), - ] - : []), - ...(cp ? copyTools(true, (job) => void runJob(job)) : []), - hold( - createButton({ - label: ft("Discard"), - variant: "ghost", - size: 28, - disabled: !busy, - onClick: () => void discard(), - }), - ), - hold( - createButton({ - label: ft("Save"), - variant: "filled", - size: 28, - disabled: !busy, - onClick: () => void save(), - }), - ), - ] - : []; - return el("div", { - className: "fdoc__sum", - children: [ - el("div", { className: "fdoc__sumleft", children: bits }), - el("div", { className: "fdoc__tools", children: tools }), - ], - }); - }; - - const draw = (): void => { - rows.beforeDraw(); - if (!doc || !answer) { - [head, grid, side, foot].forEach((x) => x.replaceChildren()); - return; - } - // M01 저장 전 새 로직(키 없음)도 고침 — [수식 저장] · [고침 버림]은 키가 생긴 뒤(페이지 [저장] → 새 키로 이어 감) - // M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만 - const edit = opt.admin && (!!key || !cp); - const vals = answer.값 ?? {}; - const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); - const lineSet = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)); - const shown = reach(doc, vals, showOff); - const markMap = new Map(); - for (const m of answer.표지 ?? []) { - const at = m.기호 || (m as { 자리?: string }).자리; - if (at && !markMap.has(at)) markMap.set(at, m.글); - } - const options = optionBar(); - head.replaceChildren( - ...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []), - toolRow(edit && !!key), - ); - const { cells, order } = buildLines({ - admin: edit, - showOff, - doc, - answer, - vars, - marks: markMap, - editing: rows.editing(), - mount: rows.mount, - onEditAt: rows.editAt, - onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)), - onPickVar, - onGroupName: (old, now) => - change((d) => - (d.금액 ?? []).forEach((l) => { - if ((l.묶음 ?? "") !== old) return; - if (now) l.묶음 = now; - else delete l.묶음; - }), - ), - onRedraw: () => redraw(), - }); - rows.setOrder(order); - const defs = buildDefs({ - admin: edit, - vars, - names: cache.names, - lines: lineSet, - shown, - unused: [...vars.keys()].filter((id) => !shown.includes(id)), - need: new Set( - shown.filter((id) => !lineSet.has(id) && (vars.get(id)?.연결?.갈래 ?? "식") === "식"), - ), - open: openDef, - onOpen: (id) => { - openDef = id; - draw(); - }, - setLink: (id, link: Link) => - change((d) => { - const clean = Object.fromEntries(Object.entries(link).filter(([, x]) => x !== undefined)); - ensureVar(d, id).연결 = clean as Link; - }), - setVar: (id, patch) => change((d) => Object.assign(ensureVar(d, id), patch)), - rename: (id, sign, low, up) => { - const copy = clone(doc!); - try { - rename(copy, id, sign, low, up); - } catch (e) { - if (e instanceof DocError) return ft("Bad_Sign"); - throw e; - } - openDef = varId({ 기호: sign, 아래: low, 위: up }); - change((d) => Object.assign(d, copy)); - return null; - }, - dropVar: (id) => { - openDef = null; - change((d) => { - d.변수 = (d.변수 ?? []).filter((v) => varId(v) !== id); - d.식 = (d.식 ?? []).filter((l) => l.기호 !== id); - }); - }, - makeLine: (id) => { - if (lineSet.has(id)) return; - const i = (doc!.식 ?? []).length; - change((d) => (d.식 ??= []).push({ 기호: id, 식: "" })); - rows.editAt({ kind: "line", i }); - }, - ask: (kind, words, group) => - call<{ 후보: Pick[]; 더있음: boolean }>("/links", { 갈래: kind, 검색: words, 분류: group }), - table: tableOf, - subInputs: (k) => - (subDoc(k)?.변수 ?? []) - .filter((v) => ["선택", "입력", "숫자"].includes(v.연결?.갈래 ?? "") && v.열쇠) - .map((v) => v.열쇠!), - sub, - }); - const symHead = el("div", { - className: "fdoc__colhead fdoc__symhead", - children: [ - el("span", { text: ft("Sym_Head") }), - ...(rows.editing() ? [mathToolbar(rows.editingMoney() ? rows.splitGroup : undefined)] : []), - ], - }); - grid.replaceChildren( - symHead, - el("div", { className: "fdoc__colhead", text: ft("Val_Head") }), - ...cells, - ); - side.replaceChildren(defs); - foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)])); - }; - - /** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */ - const redraw = (): void => { - if (!doc) return; - const before = JSON.stringify(doc); - sweep(doc); - if (JSON.stringify(doc) !== before) { - keep(); - recalc(); - } - draw(); - }; - - const take = (got: Loaded): void => { - cache.merge(got.연결); - saved = JSON.stringify(got.문서); - doc = clone(got.문서); - if (!cp) return; - calls = got.부름 ?? {}; - cut = clone(got.끊음 ?? {}); - savedCut = JSON.stringify(cut); - mark = got.사본; - version = got.판 ?? ""; - }; - - const body = (): Record => - cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc }; - - /** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */ - const runJob = async (job: CopyJob): Promise => { - if (!doc || !cp) return; - if (dirty()) return void showToast(ft("Dirty_First"), "warning"); - showLoadingOverlay(); - let got: JobReply; - try { - got = await call("/preview", { 할일: job }); - } catch (e) { - hideLoadingOverlay(); - return void showToast( - `${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, - "error", - ); - } - hideLoadingOverlay(); - const names = new Map(Object.entries({ ...calls, ...cut }).map(([k, d]) => [k, d.이름 ?? ""])); - if (await askJob(job, got, names)) await save({ 할일: job, 판: version }, ft("Job_Done")); - }; - - const save = async (send?: Record, done = ft("Saved")): Promise => { - if (!doc) return; - rows.flush(); - if (!send && !dirty()) return void draw(); - send ??= body(); - showLoadingOverlay(); - try { - const got = await call("/save", send).catch(async (e: { status?: number }) => { - // 사본 판 바뀜(페이지 [저장] 등) — 수식 사본이 그대로면 새 판으로 한 번 더 - if (!cp || e.status !== 409) throw e; - const now = await call(""); - if ( - JSON.stringify([now.문서, now.끊음 ?? {}]) !== - JSON.stringify([JSON.parse(saved), JSON.parse(savedCut)]) - ) - throw e; - return call("/save", { ...send, 판: (version = now.판 ?? "") }); - }); - take(got); - keep(); - recalc(); - draw(); - if (cp && got.판) cp.onSaved?.(got.판); - showToast(done, "success"); - } catch (e) { - showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); - } finally { - hideLoadingOverlay(); - } - }; - - const back = (): void => { - doc = JSON.parse(saved) as FormulaDoc; - cut = JSON.parse(savedCut) as Docs; - cache.merge({ 문서: { ...calls, ...cut } }); - }; - - const discard = async (): Promise => { - rows.reset(); - if (!doc || !dirty()) return void draw(); - back(); - openDef = null; - keep(); - recalc(); - draw(); - }; - - /** 문서 열기 — 같은 키면 그대로(다시 그림만) */ - const open = async (next: string): Promise => { - if (next === key && doc) return; - rows.reset(); - const was = key; - key = next; - doc = answer = null; - given = {}; - showOff = false; - openDef = null; - asked = ""; - cache = createDocCache(); - tables.clear(); - [cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""]; - draw(); - if (!next) { - // 저장 전 새 로직 — 세 영역 빈 화면(누르면 입력 · 고친 것은 캐시 "" 열쇠) - doc = blankDoc(); - saved = JSON.stringify(doc); - keep(); // 옛 새 로직 고침(「""」 열쇠) 걷음 - recalc(); - return draw(); - } - const mine = ++seq; - try { - const got = await call(cp ? "" : `?key=${encodeURIComponent(next)}`); - if (mine !== seq || key !== next) return; - take(got); - const all = drafts(); - // 저장 전 새 로직에서 고친 문서 — 페이지 [저장]으로 생긴 새 키(빈 문서)로 이어 감 - if (!was && got.새 && all[""] && !all[next]) { - all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 }; - delete all[""]; - sessionStorage.setItem(opt.cache, JSON.stringify(all)); - } - const draft = all[next]; - if (draft) doc = clone(draft); - const kept = cp ? (drafts()[`${next}#끊음`] as unknown as Docs | undefined) : undefined; - if (kept) cache.merge({ 문서: (cut = clone(kept)) }); - recalc(); - draw(); - } catch (e) { - if (mine === seq) - head.replaceChildren( - el("p", { - className: "ui-text ui-text--muted", - text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`, - }), - ); - } - }; - - const canLeave = async (): Promise => { - rows.flush(); - if (!dirty()) return true; - if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; - back(); - keep(); - return true; - }; - - return { - root, - /** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */ - draw: (next: string): void => void open(next), - canLeave, - /** 시험 · 확인용 — 지금 문서 · 셈 */ - state: () => ({ doc, answer, dirty: dirty() }), - }; -} diff --git a/A00_Common/formula_doc_sub.ts b/A00_Common/formula_doc_sub.ts deleted file mode 100644 index 16aa9f5b9..000000000 --- a/A00_Common/formula_doc_sub.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* ============================================================================= - * formula_doc_sub.ts - * 단가산출 연결 펼침(PLAN 802-3 · 802-4b-2 · 요구 659-3 · 796-2e 뜻) — 정의 영역에서 단가산출 기호를 열면 그 문서의 식 · 값 식 - * M01 = 읽기만 · M02 사본(`copy`) = 머리 「연결」 + [연결 끊기](이 사본 안 자기 문서로 굳힘 · 금액 그대로) · - * 「끊음」 + [다시 잇기](고친 것 버림 · 부름 문서로) · 끊은 문서 = 그 자리에서 식 줄 · 금액 줄 · 숫자 고침(`formula_doc_edit.ts`) - * · 줄 편집 = 수식 영역과 같은 편집기(`formula_doc_math.ts` · 803-2a) · 맨 아래 계 줄 - * 셈 = 같은 계산기 · 연결 캐시(끊은 문서는 캐시 문서를 덮어씀 — 부른 쪽이 `merge`) - * ========================================================================== */ - -import { createBadge } from "@ui/ui_template_badge"; -import { createButton } from "@ui/ui_template_button"; -import { eqText } from "@ui/ui_template_equation"; -import { editNumber } from "@ui/ui_template_equation_edit"; -import { el } from "@ui/ui_template_elements"; -import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc"; -import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; -import type { DocCache } from "../common_util/common_util_formula_doc_cache"; -import { editLit, editRow, ensureVar, sweep } from "./formula_doc_edit"; -import { buildLines, sumRow, type RowRef } from "./formula_doc_lines"; -import { openMath } from "./formula_doc_math"; -import { evalNode, tryParse } from "./formula_doc_render"; -import { ft } from "./formula_doc_text"; - -export interface SubCopy { - /** 층 쓰기 권한 */ - edit: boolean; - /** 이 사본이 부르는 키(부름 · 끊음) */ - has: (key: string) => boolean; - /** 끊은 문서(없으면 연결) */ - cut: (key: string) => FormulaDoc | undefined; - onCut: (key: string) => void; - onJoin: (key: string) => void; - /** 끊은 문서 고침 — 캐시 · 셈 · 그림까지 부른 쪽이 */ - change: (key: string, fn: (d: FormulaDoc) => void) => void; -} - -export interface SubHooks { - doc: FormulaDoc | null; - answer: DocAnswer | null; - cache: DocCache; - copy?: SubCopy; -} - -/** 끊은 문서에서 편집 중인 줄 — 다음 그림에서 편집기를 엶 */ -let next: { key: string; ref: RowRef; at?: { x: number; y: number }; seed?: string } | null = null; - -const subDoc = (cache: DocCache, k: string): FormulaDoc | null => { - try { - return cache.links.doc(k) ?? null; - } catch { - cache.takeMissing(); - return null; - } -}; - -function linkHead(k: string, c: SubCopy): HTMLElement | null { - if (!c.has(k)) return null; - const cut = !!c.cut(k); - const tools = c.edit - ? [ - createButton({ - label: cut ? ft("Relink") : ft("Unlink"), - variant: "ghost", - size: 24, - onClick: () => (cut ? c.onJoin(k) : c.onCut(k)), - }), - ] - : []; - const head = el("div", { - className: "fdoc__sublink", - attrs: { "data-sub-link": cut ? "cut" : "linked" }, - children: [createBadge(cut ? ft("Cut") : ft("Linked"), cut ? "warning" : "info"), ...tools], - }); - return head; -} - -/** 단가산출 연결 펼침 — 그 문서의 식 · 값 식(끊은 사본 문서면 고침) */ -export function subBlock(id: string, h: SubHooks): HTMLElement | null { - const link: Link | undefined = h.doc?.변수?.find((v) => varId(v) === id)?.연결; - if (link?.갈래 !== "단가산출" || !link.키 || link.키.includes("{")) return null; - const k = link.키; - const sub = subDoc(h.cache, k); - if (!sub) return null; - const inputs: Record = {}; - for (const [name, t] of Object.entries(link.입력 ?? {})) { - const n = tryParse(t); - const v = n ? evalNode(n, h.answer?.값 ?? {}) : undefined; - if (v !== undefined) inputs[name] = v; - } - const got = calcDoc(sub, h.cache.links, inputs); - h.cache.takeMissing(); - const c = h.copy; - const live = !!(c?.edit && c.cut(k)); - const put = (fn: (d: FormulaDoc) => void): void => c!.change(k, fn); - const none = (): void => undefined; - const editing = next?.key === k ? next : null; - next = null; - const box = el("div", { className: "fdoc__grid fdoc__sub" }); - const edit = (ref: RowRef | "end", at?: { x: number; y: number }): void => { - if (ref !== "end") { - next = { key: k, ref, at }; - return put(() => undefined); - } - const money = (sub.금액 ?? []).length; - next = { key: k, ref: { kind: money ? "money" : "line", i: money || (sub.식 ?? []).length } }; - put((d) => { - const last = d.금액?.[d.금액.length - 1]; - if (last) { - d.금액!.push({ - 이름: "", - 비목: last.비목 ?? "노무비", - 단가: "", - ...(last.묶음 ? { 묶음: last.묶음 } : {}), - }); - next!.seed = `${last.비목 ?? "노무비"} `; - } else (d.식 ??= []).push({ 기호: "", 식: "" }); - }); - }; - const mount = (host: HTMLElement, ref: RowRef, text: string): void => { - const first = editing?.seed ?? text; - queueMicrotask(() => - openMath(host, { - text: first, - at: editing?.at ?? "end", - onLeave: (now) => - put((d) => { - if (now !== first) editRow(d, ref.kind, ref.i, now); - sweep(d); - }), - }), - ); - }; - box.append( - ...buildLines({ - admin: live, - doc: sub, - answer: got, - vars: new Map((sub.변수 ?? []).map((v) => [varId(v), v])), - marks: new Map(), - editing: editing?.ref ?? null, - mount, - onEditAt: live ? edit : none, - onLit: live ? (w, i, at, v) => put((d) => editLit(d, w, i, at, v)) : none, - onPickVar: (vid, num) => { - const v = sub.변수?.find((x) => varId(x) === vid); - if (!live || !num || v?.연결?.갈래 !== "숫자") return; - editNumber(num, (t) => { - const n = Number(t.replace(/,/g, "")); - put((d) => (ensureVar(d, vid).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t })); - }); - }, - onGroupName: none, - onRedraw: () => put((d) => sweep(d)), - }).cells, - ); - if ((sub.금액 ?? []).length) - box.append(...sumRow(ft("Sum"), got.셈됨 ? (got as unknown as Record) : null)); - if (sub.결과) { - const r = got.결과; - box.append( - el("div", { className: "fdoc__cell fdoc__total", text: ft("Result") }), - el("div", { - className: "fdoc__cell fdoc__total", - text: typeof r === "number" ? `${eqText(r)} ${sub.결과단위 ?? ""}` : "—", - }), - ); - } - const head = c ? linkHead(k, c) : null; - if (!head) return box; - return el("div", { attrs: { "data-sub": k }, children: [head, box] }); -} diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index 63e5f3cb7..577140044 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -299,3 +299,16 @@ flex-direction: column; gap: var(--spacing-8); } + +/* 옵션 가로(옛 수식 문서 CSS 에서 옮김 · 804-6) */ +.fdoc__options { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-12); + align-items: flex-end; +} + +.fdoc__options > * { + width: auto; + min-width: 8em; +} diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index fd8041734..056dc5aaa 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -1,13 +1,14 @@ /* ============================================================================= * line_stack_screen.ts * 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈) - * 위 = 옵션 가로(수식 탭과 같은 부품 `formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기) + * 위 = 옵션 가로(`formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기) · M02 사본 [단가 갱신] · [재생성] * 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침) - * 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 수식 탭과 같은 부품) - * 정본 = 수식 문서(수식 탭과 같은 문서 · 같은 길 · 같은 캐시 열쇠 — 탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` + * 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계) + * 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭 * 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) * 고침 = 캐시(sessionStorage)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음 - * M02 사본(`copy`) — 저장 몸 {문서, 끊음(수식 탭이 끊은 문서 그대로), 판} · [단가 갱신] · [재생성]은 수식 탭 + * M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더 + * [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -31,7 +32,15 @@ import { type StackDoc, type StackItem, } from "../common_util/common_util_line_stack"; -import { copyBadges, type CopyMark, type CopyOptions } from "./formula_doc_copy"; +import { + askJob, + copyBadges, + copyTools, + type CopyJob, + type CopyMark, + type CopyOptions, + type JobReply, +} from "./formula_doc_copy"; import { docFoot } from "./formula_doc_foot"; import { buildOptions } from "./formula_doc_options"; import { ft } from "./formula_doc_text"; @@ -40,14 +49,13 @@ import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { buildEdit } from "./line_stack_edit"; import { buildOut } from "./line_stack_out"; import { lt } from "./line_stack_text"; -import "./formula_doc.css"; import "./line_stack.css"; export interface StackScreenOptions { /** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links */ base: string; admin: boolean; - /** sessionStorage 열쇠 — 수식 탭과 같게(같은 문서 고침을 이어 받음) */ + /** sessionStorage 열쇠 */ cache: string; copy?: CopyOptions; /** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */ @@ -122,7 +130,7 @@ export function createStackScreen(opt: StackScreenOptions) { return data; }; - // ── 캐시(수식 탭과 같은 열쇠) ── + // ── 캐시 ── const drafts = (): Record => { try { return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record; @@ -191,7 +199,7 @@ export function createStackScreen(opt: StackScreenOptions) { } }; - /** 표 하나 — 수식 탭과 같은 길(`/table`) · 받은 표는 계산기 캐시에도 */ + /** 표 하나(`/table`) — 받은 표는 계산기 캐시에도 */ const tableOf = (k: string): Promise => { if (!tables.has(k)) tables.set( @@ -264,6 +272,7 @@ export function createStackScreen(opt: StackScreenOptions) { const tools = edit() && key ? [ + ...(cp ? copyTools(true, (job) => void runJob(job)) : []), createButton({ label: ft("Discard"), variant: "ghost", @@ -356,22 +365,44 @@ export function createStackScreen(opt: StackScreenOptions) { version = got.판 ?? ""; }; - const save = async (): Promise => { - if (!doc || !dirty()) return; - const kept = drafts()[`${key}#끊음`] as unknown as Docs | undefined; // 수식 탭이 끊은 문서 고침 - const send = cp ? { 문서: doc, 끊음: kept ?? cut, 판: version } : { 문서: doc }; + /** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */ + const runJob = async (job: CopyJob): Promise => { + if (!doc || !cp) return; + if (dirty()) return void showToast(ft("Dirty_First"), "warning"); + showLoadingOverlay(); + let got: JobReply; + try { + got = await call("/preview", { 할일: job }); + } catch (e) { + hideLoadingOverlay(); + return void showToast( + `${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, + "error", + ); + } + hideLoadingOverlay(); + if (await askJob(job, got, cache.names)) await save({ 할일: job, 판: version }, ft("Job_Done")); + }; + + const save = async (job?: Record, done = ft("Saved")): Promise => { + if (!doc || (!job && !dirty())) return; + const send = job ?? (cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc }); + const was = saved; showLoadingOverlay(); try { - const got = await call("/save", send); + const got = await call("/save", send).catch(async (e: { status?: number }) => { + // 사본 판 바뀜(페이지 [저장] 등) — 문서가 받은 그대로면 새 판으로 한 번 더 + if (!cp || e.status !== 409) throw e; + const now = await call(""); + if (JSON.stringify(now.문서) !== was) throw e; + return call("/save", { ...send, 판: (version = now.판 ?? "") }); + }); take(got); - const all = drafts(); - delete all[`${key}#끊음`]; - sessionStorage.setItem(opt.cache, JSON.stringify(all)); keep(); recalc(); draw(); if (cp && got.판) cp.onSaved?.(got.판); - showToast(ft("Saved"), "success"); + showToast(done, "success"); } catch (e) { showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error"); } finally { @@ -426,8 +457,6 @@ export function createStackScreen(opt: StackScreenOptions) { sessionStorage.setItem(opt.cache, JSON.stringify(all)); } if (all[next]) doc = clone(all[next] as StackDoc); - if (cp && all[`${next}#끊음`]) - cache.merge({ 문서: (cut = clone(all[`${next}#끊음`] as unknown as Docs)) }); recalc(); draw(); } catch (e) { @@ -443,7 +472,7 @@ export function createStackScreen(opt: StackScreenOptions) { const canLeave = async (): Promise => { if (!dirty()) return true; - if (!(key in drafts())) return true; // 수식 탭에서 이미 버림 + if (!(key in drafts())) return true; // 다른 화면에서 이미 버림 if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; doc = JSON.parse(saved) as StackDoc; keep(); diff --git a/A00_Common/sheet_formula_cell.ts b/A00_Common/sheet_formula_cell.ts deleted file mode 100644 index 1a36ab683..000000000 --- a/A00_Common/sheet_formula_cell.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* ============================================================================= - * A00_Common/sheet_formula_cell.ts - * 줄 식 시트 식 칸 한 벌(PLAN 792 · 794-1) — M02 단가산출 시트 · M01 로직 시트가 같이 씀. - * 칠 때마다 검사(`check` · 150 ms 쉼 · 앞 요청 끊음) → 틀리면 빨간 칸 + 까닭 · 알림 한 줄 - * 바꿀 때(Enter · 떠남) — 빈 칸 · 원래 식 그대로 = `put(null)` · 되면 `put(엔진 식)` · 틀리면 안 넣음 - * 치는 중 글 = `drafts`(다시 그려도 안 잃음) · 부품 = 입력 · 글 · 서버 검사 길은 부르는 쪽 몫 - * ========================================================================== */ - -import { el } from "@ui/ui_template_elements"; -import { createInputField } from "@ui/ui_template_input"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createText } from "@ui/ui_template_text"; - -const TEXT = { - Formula: ["식", "Formula"], - Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"], - Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"], - Checking: ["검사 중", "Checking…"], -} as const satisfies Record; -const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; - -/** 검사 답 — 되나 · 까닭 · 알림(없으면 빈) · 엔진 식 */ -export interface FormulaVerdict { - 되나: boolean; - 까닭: string[]; - 알림?: string[]; - 식: string | null; -} - -export interface FormulaCellOptions { - /** 시트 칸 열쇠(초점 되찾기 · 치는 중 글) */ - key: string; - /** 칸 그릇 이름표 값(`data-sheet-fx`) */ - spot: string; - shown: string; - /** 원래 식 글 — 그대로 두면 `put(null)` */ - origin: string; - drafts: Map; - check: (text: string, signal: AbortSignal) => Promise; - put: (engine: string | null) => void; - className?: string; -} - -export function createFormulaCell(o: FormulaCellOptions): HTMLElement { - const box = createInputField({ - value: o.drafts.get(o.key) ?? o.shown, - compact: true, - size: 24, - placeholder: t("Hint"), - }); - box.input.setAttribute("aria-label", t("Formula")); - box.input.dataset.sheetCell = o.key; - box.input.spellcheck = false; - const note = createText("", "muted", 12); - note.dataset.sheetCheck = o.key; - let timer = 0; - let ctl: AbortController | null = null; - const run = (text: string): Promise => { - ctl?.abort(); - const mine = (ctl = new AbortController()); - note.textContent = t("Checking"); - return o.check(text, mine.signal).then( - (got) => { - if (mine !== ctl) return got; - box.setError(got.되나 ? undefined : got.까닭.join(" · ")); - note.textContent = (got.알림 ?? []).join(" · "); - return got; - }, - (error: unknown) => { - if (mine !== ctl || mine.signal.aborted) return null; - box.setError(error instanceof Error ? error.message : String(error)); - note.textContent = ""; - return null; - }, - ); - }; - box.input.addEventListener("input", () => { - o.drafts.set(o.key, box.input.value); - window.clearTimeout(timer); - const text = box.input.value.trim(); - if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = "")); - timer = window.setTimeout(() => void run(text), 150); - }); - box.input.addEventListener("change", async () => { - window.clearTimeout(timer); - const text = box.input.value.trim(); - if (!text || text === o.origin) { - o.drafts.delete(o.key); - return o.put(null); - } - const got = await run(text); - if (!got?.되나 || !got.식) return void (note.textContent = t("Bad")); - o.drafts.delete(o.key); - o.put(got.식); - }); - return el("div", { - className: o.className ?? "m02-sks__fx", - attrs: { "data-sheet-fx": o.spot }, - children: [box.root, note], - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 9d7efe094..4b9e661ec 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(802-3 수식 문서 `A00_Common/formula_doc_screen.ts`) · 단가산출 상세 / 오른쪽 원문 + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) · 단가산출 상세 / 오른쪽 원문 * 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run` * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * @@ -43,7 +43,6 @@ import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; -import { createDocScreen } from "../A00_Common/formula_doc_screen"; import { createStackScreen } from "../A00_Common/line_stack_screen"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; @@ -108,22 +107,15 @@ export async function mountM01LogicLab( .catch(() => false); let recalc: number | undefined; const drive = createDrive(); - // 802-3 수식 = 수식 문서 화면(공용 · 옛 수식 나무 화면은 M01 에서 걷음) - const equation = createDocScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" }); - // 804-2 줄 쌓기 = 같은 수식 문서 · 같은 캐시 — 탭을 바꾸면 고른 쪽이 다시 읽음 + // 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts const stack = createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" }); - const leave = (): Promise => equation.canLeave().then((ok) => ok && stack.canLeave()); + const leave = (): Promise => stack.canLeave(); const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」 const panes = buildLogicSide(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽 const views = createViewPicker(editor, (v) => { - const k = opened?.origKey; - if (v === "stack") stack.draw(k ?? "", true); - else if (v === "equation" && k) { - equation.draw(""); - equation.draw(k); - } + if (v === "stack") stack.draw(opened?.origKey ?? "", true); }); const errors = el("div", { className: "ui-banner ui-banner--danger m01-logic__reasons", @@ -254,10 +246,8 @@ export async function mountM01LogicLab( onTrial: drawCalc, rerun: () => void drive.run(), admin, - equation: equation.root, stack: stack.root, }); - equation.draw(current.origKey ?? ""); stack.draw(current.origKey ?? ""); }; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 274e98953..f91ac4350 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Detail.ts - * 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로(수식 탭 796-2 포함) - * ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(796-2) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) + * 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로 + * ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(줄 쌓기 804-2 · 804-6) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) * 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 보기는 799-2 에 걷음 — 셈은 화면 칸 없이 `_Logic_Drive.ts` 구동 * 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜) * ========================================================================== */ @@ -36,9 +36,7 @@ export interface DetailContext extends HoContext { onTrial?: () => void; /** 717-2b M02 단가산출 — 단가산출 상세 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ cover?: { ho: HTMLElement; open?: ReturnType }; - /** 796-2 수식 탭(`_LogicLab_Equation`) 그릇 — 있으면 「수식」 덩이(M02 는 795-1 사본 꼴 뒤에 이음) */ - equation?: HTMLElement; - /** 804-2 줄 쌓기 탭 그릇(`A00_Common/line_stack_screen.ts`) */ + /** 수식 탭 = 줄 쌓기 그릇(`A00_Common/line_stack_screen.ts` · 804-2 · 804-6) */ stack?: HTMLElement; } @@ -230,7 +228,6 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { // 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 host.replaceChildren( ...infoBox(ctx), - ...(ctx.equation ? [block(ctx.equation, "equation")] : []), ...(ctx.stack ? [block(ctx.stack, "stack")] : []), ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx), ); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css deleted file mode 100644 index 982f2475a..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css +++ /dev/null @@ -1,113 +0,0 @@ -/* 수식 탭(796-2) — 배치만 · 식 그림은 공통 수식 부품 · 색은 공통 값 */ -.m01-eq { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - container-type: inline-size; -} - -.m01-eq__options .ui-toolbar { - align-items: flex-end; - gap: var(--spacing-8) var(--spacing-12); -} - -.m01-eq__opt { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - max-width: 14rem; -} - -.m01-eq__opt > .ui-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.m01-eq__ctl { - min-width: 0; -} - -.m01-eq__ctl :is(select, input) { - width: 100%; - max-width: 14rem; - text-overflow: ellipsis; -} - -.m01-eq__notice:empty { - display: none; -} - -.m01-eq__edit { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border-strong); - border-radius: var(--radius-sm); - background: var(--color-surface-raised); -} - -.m01-eq__fx { - min-width: 0; -} - -.m01-eq__form { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); - gap: var(--spacing-8) var(--spacing-12); -} - -.m01-eq__why:empty { - display: none; -} - -.m01-eq__why { - margin-top: var(--spacing-8); -} - -/* 800-2 — 왼쪽 수식 영역 · 오른쪽 변수 정의 영역 */ -.m01-eq__split { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem); - gap: var(--spacing-12); - align-items: start; -} - -.m01-eq__left { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - -@container (max-width: 700px) { - .m01-eq__split { - grid-template-columns: minmax(0, 1fr); - } - - .m01-eq__split > .ui-eq__defs { - border-left: 0; - border-top: 1px solid var(--color-border); - } -} - -.m01-eq__def-edit, -.m01-eq__draft { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8); - border: 1px solid var(--color-border-strong); - border-radius: var(--radius-sm); - background: var(--color-surface-raised); -} - -.m01-eq__def-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--spacing-8); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts deleted file mode 100644 index 348cc5e5a..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ /dev/null @@ -1,616 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_Equation.ts - * M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이). - * 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1) - * 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만) - * 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음) - * 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭) - * · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서 - * · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인) - * 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음) - * 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게) - * 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { createBanner } from "@ui/ui_template_banner"; -import { createBadge } from "@ui/ui_template_badge"; -import { createButton } from "@ui/ui_template_button"; -import { createEqChain } from "@ui/ui_template_equation_line"; -import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs"; -import { createEqSum } from "@ui/ui_template_equation_sum"; -import { - eqText, - type EqLine, - type EqSumHooks, - type EqNode, - type EqTree, -} from "@ui/ui_template_equation"; -import { lineId } from "@ui/ui_template_equation_line"; -import { - el, - hideLoadingOverlay, - showConfirmDialog, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { createInputField } from "@ui/ui_template_input"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createText } from "@ui/ui_template_text"; -import { createToolbar } from "@ui/ui_template_toolbar"; -import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell"; -import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; -import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; -import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api"; -import { - draftRow, - renameEditor, - type Draft, - type DefsHost, -} from "./M01_MasterData_UI_LogicLab_EquationDefs"; -import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar"; -import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; -import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model"; -import "./M01_MasterData_UI_LogicLab_Equation.css"; - -const TEXT = { - Title: ["수식", "Equation"], - Save: ["수식 저장", "Save equation"], - Discard: ["수식 고침 버림", "Discard edits"], - Add: ["변수 더함", "Add variable"], - Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"], - EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"], - DiscardPage: ["수식 원래로", "Back to copy"], - Now: ["지금", "Now"], - Before: ["고치기 전", "Before"], - Blocked: [ - "시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)", - "Unsaved edits elsewhere — save or discard first (showing saved version)", - ], - Options: ["옵션", "Options"], - Wait: ["수식을 불러오는 중", "Loading equation"], - Hint: [ - "식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )", - "Click a formula to edit · keyboard + − × ÷ / ( )", - ], - Apply: ["연결 적용 · 식 넣기", "Apply links and formula"], - Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"], - Leave_Ok: ["버림", "Discard"], - Saved: ["수식을 저장함", "Equation saved"], - Total: ["계", "Total"], -} as const satisfies Record; -const t = (k: keyof typeof TEXT, n = 0): string => - (TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n)); - -interface Read { - ok?: boolean; - reason?: string; - 수식?: EqTree; - 되나?: boolean; - 까닭?: string[]; - 줄?: LogicRow; - 전?: Record; - 후?: Record; -} - -async function post(path: string, body: unknown, signal?: AbortSignal): Promise { - const r = await fetch(`${API_BASE_URL}/m01${path}`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - signal, - }); - const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; - if (!r.ok) throw new ApiError(r.status, data.detail ?? r.statusText); - return data; -} - -const CACHE = "m01_equation_edits"; -const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록) -const loadCache = (): Record => { - try { - return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record; - } catch { - return {}; - } -}; - -/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */ -export interface EqPage { - ask: ( - suffix: "" | "/preview" | "/links", - body: Record, - signal?: AbortSignal, - ) => Promise; - edits: () => EqEdit[]; - keep: (edits: EqEdit[]) => void; - values: () => Record; - setValue: (name: string, value: string | null) => void; - /** 소계 표 덧단추 자리(796-2e M02 더한 기초 [연결 끊기]) */ - sum?: EqSumHooks; -} - -export interface LabEquationOptions { - /** 시스템 관리자 — 고침 도구 */ - admin: boolean; - /** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */ - blocked: () => boolean; - /** 저장 뒤 — 상세를 저장본으로 다시 엶 */ - onSaved: () => void; - /** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */ - page?: EqPage; -} - -/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */ -interface PendingLink { - line: EqLine; - text: string; - symbols: string[]; - rows: Map; - apply: HTMLElement; -} - -export function createLabEquation(opt: LabEquationOptions) { - const bar = el("div", { className: "m01-eq__bar" }); - const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } }); - const notice = el("div", { className: "m01-eq__notice" }); - const body = el("div", { className: "m01-eq__body" }); - const root = el("div", { - className: "m01-eq", - attrs: { "data-lab-equation": "" }, - children: [bar, optionsBar, notice, body], - }); - let key = ""; - let version = ""; - let row: LogicRow | null = null; - let rowNow: LogicRow | null = null; - let eq: EqTree | null = null; - let edits: EqEdit[] = []; - let inputs: Record = {}; - let before: Record | null = null; - let why = ""; - let editingLine: string | null = null; - let defEditing: string | null = null; - let pending: PendingLink | null = null; - let defsBox: HTMLElement = el("div"); - let ctl: AbortController | null = null; - let timer = 0; - let paintedBlocked = false; - const open = new Set(); - const drafts = new Map(); - const canEdit = (): boolean => opt.admin && !opt.blocked(); - const page = opt.page; - const vals = (): Record => page?.values() ?? inputs; - const ask = ( - suffix: "" | "/preview" | "/links", - body: Record, - signal?: AbortSignal, - ) => - page ? page.ask(suffix, body, signal) : post(`/logic/equation${suffix}`, body, signal); - - const body0 = (): Record => ({ key, inputs: inputsOf(vals()), 고침: edits }); - const persist = (): void => { - if (page) return page.keep(edits); - const all = loadCache(); - if (edits.length) all[key] = edits; - else delete all[key]; - try { - sessionStorage.setItem(CACHE, JSON.stringify(all)); - } catch { - /* 캐시가 막혀도 화면 안 고침은 남음 */ - } - }; - - /** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */ - async function refresh(): Promise { - ctl?.abort(); - const mine = (ctl = new AbortController()); - try { - const got = await ask(edits.length ? "/preview" : "", body0(), mine.signal); - if (mine !== ctl) return; - if (got.되나 === false || got.ok === false) { - why = (got.까닭 ?? [got.reason ?? ""]).join(" · "); - } else { - why = ""; - eq = got.수식 ?? null; - rowNow = got.줄 ?? null; - before = edits.length ? (got.전 ?? null) : null; - } - } catch (error) { - if (mine.signal.aborted) return; - why = error instanceof Error ? error.message : String(error); - } - paint(); - } - - /** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */ - async function commit(list: EqEdit[]): Promise { - const got = await ask("/preview", { - ...body0(), - 고침: [...edits, ...list], - }).catch((error: unknown): Read => ({ - 되나: false, - 까닭: [error instanceof Error ? error.message : String(error)], - })); - if (!got.되나) return got.까닭 ?? []; - edits = [...edits, ...list]; - persist(); - ctl?.abort(); - why = ""; - eq = got.수식 ?? eq; - rowNow = got.줄 ?? rowNow; - before = got.전 ?? before; - paint(); - drawOptions(); - return []; - } - - const askAgain = (): void => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void refresh(), 150); - }; - - // ── 옵션 가로 ────────────────────────────────────────────────────── - function optionNode(spec: NonNullable[number], auto: unknown): HTMLElement { - const item: ItemInput = { - 이름: spec.이름, - 단위: spec.단위, - 고르기: spec.고르기, - 범위: spec.범위, - 자동값: auto, - }; - const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12); - const set = (v: string | number | null): void => { - if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v)); - if (v === null || v === "") delete inputs[spec.이름]; - else inputs[spec.이름] = String(v); - askAgain(); - }; - let control: HTMLElement; - if ((spec.고르기?.length ?? 0) > BIG) { - // 찾기형 — 입력 + 목록(칠수록 좁혀짐) - const box = createInputField({ - value: vals()[spec.이름] ?? "", - compact: true, - size: 24, - placeholder: auto === null || auto === undefined ? "" : String(auto), - }); - const list = el("datalist", { - attrs: { id: `m01-eq-list-${spec.이름}` }, - children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })), - }); - box.input.setAttribute("list", list.id); - box.input.title = box.input.value || box.input.placeholder; - box.input.addEventListener("change", () => { - box.input.title = box.input.value; - set(box.input.value.trim()); - }); - control = el("div", { children: [box.root, list] }); - } else { - const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v)); - const tip = (): void => { - const c = made.control; - c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value; - }; - made.control.addEventListener("change", tip); - made.control.addEventListener("input", tip); - tip(); - control = el("div", { children: [made.control, made.error] }); - } - control.classList.add("m01-eq__ctl"); - const cell = el("div", { className: "m01-eq__opt", children: [label, control] }); - cell.dataset.eqOption = spec.이름; - return cell; - } - - function drawOptions(): void { - const specs = (rowNow ?? row)?.입력 ?? []; - const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v])); - optionsBar.replaceChildren( - ...(specs.length - ? [ - createToolbar({ - items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)), - size: 24, - label: t("Options"), - }), - ] - : []), - ); - } - - // ── 바로 고침 · 연결 지정 ────────────────────────────────────────── - const total = (sums: Record): string => ("계" in sums ? "계" : "결과"); - - const defsHost: DefsHost = { - commit, - links: (body) => ask("/links", body), - onDropped: (k) => { - if (page) page.setValue(k, null); - else { - delete inputs[k]; - drawOptions(); - } - }, - }; - - function buildDefs(): HTMLElement { - const edit = canEdit(); - const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠); - return createEqDefs(eq?.정의 ?? [], { - editing: defEditing, - onEdit: edit - ? (d) => { - defEditing = defEditing === defSign(d) ? null : defSign(d); - drawDefs(); - } - : undefined, - editor: (d) => - renameEditor(d, defsHost, () => { - defEditing = null; - drawDefs(); - }), - drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [], - head: edit - ? [ - createButton({ - label: t("Add"), - variant: "ghost", - size: 24, - iconStart: "plus", - onClick: () => openAddDialog({ mids, commit }), - }), - ] - : [], - }); - } - - function drawDefs(): void { - const next = buildDefs(); - defsBox.replaceWith(next); - defsBox = next; - } - - /** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */ - function setPending(line: EqLine, text: string, miss: string[]): void { - if (!miss.length) { - if (!pending) return; - pending = null; - return drawDefs(); - } - if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) { - [pending.line, pending.text] = [line, text]; - return; - } - const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const)); - const apply = el("div", { - className: "m01-eq__def-actions", - children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })], - }); - pending = { line, text, symbols: miss, rows, apply }; - drawDefs(); - defsBox.scrollTo({ top: 0 }); - } - - async function applyPending(): Promise { - const p = pending; - if (!p) return; - const list: EqEdit[] = []; - let missing = false; - for (const d of p.rows.values()) { - const made = d.read(); - d.say(typeof made === "string" ? made : ""); - if (typeof made === "string") missing = true; - else list.push(made); - } - if (missing) return; - const bad = await commit([...list, formulaEdit(p.line, p.text)]); - if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · "))); - drafts.delete(lineId(p.line)); - [pending, editingLine] = [null, null]; - paint(); - } - - const check = - (line: EqLine) => - async (text: string, signal: AbortSignal): Promise => { - const got = await ask( - "/preview", - { ...body0(), 고침: [...edits, formulaEdit(line, text)] }, - signal, - ); - const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []); - setPending(line, text, miss); - const now = got.후 ? eqText(got.후[total(got.후)]) : ""; - return { - 되나: !!got.되나, - 까닭: miss.length - ? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`] - : (got.까닭 ?? []), - 알림: got.되나 && now ? [`${t("Total")} ${now}`] : [], - 식: got.되나 ? text : null, - }; - }; - - /** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */ - function editor(line: EqLine, id: string): HTMLElement { - const shown = line.입력글 ?? line.식; - const close = (): void => { - [editingLine, pending] = [null, null]; - paint(); - }; - const cell = createFormulaCell({ - key: id, - spot: id, - shown, - origin: shown, - drafts, - check: check(line), - put: (text) => { - if (text === null) return pending ? undefined : close(); - void commit([formulaEdit(line, text)]).then((bad) => { - if (bad.length) showToast(bad.join(" · "), "error"); - else close(); - }); - }, - className: "m01-eq__fx", - }); - const input = cell.querySelector("input"); - // 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로 - cell.addEventListener("focusout", () => - window.setTimeout(() => { - if (editingLine === id && !pending && !cell.contains(document.activeElement)) { - if ((input?.value.trim() ?? shown) === shown) close(); - } - }, 120), - ); - queueMicrotask(() => input?.focus()); - return cell; - } - - const showDef = (node: EqNode): void => flashEqDef(defsBox, node); - - // ── 그림 ────────────────────────────────────────────────────────── - function drawBar(): void { - const sums = eq?.합계 ?? {}; - const mark = total(sums); - const now = sums[mark]; - const old = before?.[mark]; - const diff = Number(now) - Number(old); - const items: (HTMLElement | "divider" | "spacer")[] = [ - createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14), - ]; - if (before) { - items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14)); - if (Number.isFinite(diff) && diff) - items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning")); - } - if (canEdit()) { - items.push("spacer"); - if (edits.length) { - items.push( - createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"), - createButton({ - label: t(page ? "DiscardPage" : "Discard"), - variant: "ghost", - size: 24, - onClick: () => void discard(), - }), - ...(page - ? [] - : [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]), - ); - } else items.push(createText(t("Hint"), "muted", 12)); - } - bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") })); - } - - function paint(): void { - paintedBlocked = opt.blocked(); - drawBar(); - notice.replaceChildren( - ...(paintedBlocked && opt.admin - ? [createBanner({ tone: "warning", text: t("Blocked") })] - : []), - ...(why ? [createBanner({ tone: "danger", text: why })] : []), - ); - if (!eq) { - body.replaceChildren(createText(t("Wait"), "muted")); - return; - } - const edit = canEdit(); - const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠); - const sum = createEqSum(eq, open, page?.sum, keys); - const left = el("div", { - className: "m01-eq__left", - children: [ - createEqChain(eq.줄, { - path: "", - open, - resultUnit: eq.결과단위, - onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함) - editing: editingLine, - onEdit: edit - ? (_line, id) => { - editingLine = id; - paint(); - } - : undefined, - editor: edit ? editor : undefined, - after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null), - }), - ...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계 - ], - }); - defsBox = buildDefs(); - body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] })); - } - - async function save(): Promise { - if (!canEdit() || !edits.length) return; - showLoadingOverlay(); - try { - const done = await saveRevised((ok) => - post("/logic/equation/save", { ...body0(), version, 개정확인: ok }), - ); - if (!done) return; // 개정 확인 취소 = 고침 그대로 - [edits, editingLine] = [[], null]; - persist(); - showToast(t("Saved"), "success"); - await refresh(); - drawOptions(); - opt.onSaved(); - } catch (error) { - const detail = - error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null; - showToast( - detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)), - "error", - ); - } finally { - hideLoadingOverlay(); - } - } - - async function discard(): Promise { - edits = []; - [editingLine, defEditing, pending] = [null, null, null]; - drafts.clear(); - persist(); - await refresh(); - drawOptions(); - } - - return { - root, - /** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */ - draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => { - const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈 - version = nextVersion; - row = nextRow; - if (nextKey !== key) { - [key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null]; - [defEditing, pending] = [null, null]; - inputs = {}; - edits = page ? page.edits() : (loadCache()[nextKey] ?? []); - open.clear(); - drafts.clear(); - paint(); - drawOptions(); - void refresh().then(drawOptions); - } else if (moved) { - if (page) edits = page.edits(); - void refresh().then(drawOptions); - } else if (opt.blocked() !== paintedBlocked) paint(); - }, - dirty: (): boolean => edits.length > 0, - /** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */ - canLeave: async (): Promise => { - if (!edits.length) return true; - if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false; - await discard(); - return true; - }, - }; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts deleted file mode 100644 index 72712c742..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts +++ /dev/null @@ -1,323 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_EquationDefs.ts - * 수식 탭 변수 정의 영역 속 고침(PLAN 800-3 · 요구 660-5) — 796-2a 변수 창을 정의 영역 안으로 옮겨 다시 씀. - * ① 이름 · 기호 · 첨자 고침 · 지움 = 정의 줄 아래 칸(`renameEditor`) — 쓰는 변수 지움은 서버가 막고 쓰는 곳을 알림 - * ② 새 기호 연결 지정 = 식에 처음 쓴 기호마다 줄(`draftRow`) — 국문 이름 + 연결(입력 · 표 찾기 · 요소 · 로직 · 상수) → 고침 `연결` 한 건 - * 표 · 요소 · 로직 후보 = 서버 `…/equation/links`(읽기만) · 연결 글(엔진 식)은 서버가 지음 — 화면은 고른 것만 보냄 - * 기호 글 쪼개기(`symbolParts`) = 서버 키보드 식 변수 글(`master_equation_edit._TOKEN` 기호_아래^위)과 같은 꼴 — 시험 `test_800_2_equation_layout` - * 값 · 단위는 고치지 않음(요구 660-6) · 계산 · 검사는 서버 한 벌 - * ========================================================================== */ - -import type { EqDef } from "@ui/ui_template_equation"; -import { createBanner } from "@ui/ui_template_banner"; -import { createButton } from "@ui/ui_template_button"; -import { el } from "@ui/ui_template_elements_base"; -import { createInputField, createSelectField } from "@ui/ui_template_input"; -import { varEdits, type EqEdit } from "./logic/M01_Logic_Equation_Model"; - -export interface Candidate { - 키: string; - 이름: string; - 규격?: string; - 조건?: string[]; - 칸?: string[]; - 입력?: string[]; -} - -export interface DefsHost { - commit: (edits: EqEdit[]) => Promise; - links: (ask: Record) => Promise<{ 후보: Candidate[]; 더있음: boolean }>; - onDropped: (key: string) => void; -} - -/** 새 기호 글 → 열쇠 · 기호 · 아래 · 위(`Q_d^2` → 열쇠 `Q_d2` · 기호 Q · 아래 d · 위 2) */ -export function symbolParts(token: string): Pick { - const [main = "", up = ""] = token.split("^"); - const [sign = "", ...low] = main.split("_"); - return { 열쇠: token.replace(/[^\p{L}\p{N}_]/gu, ""), 기호: sign, 아래: low.join("_"), 위: up }; -} - -const grid = (...kids: HTMLElement[]): HTMLElement => - el("div", { className: "m01-eq__form", children: kids }); - -const field = (label: string, value = ""): ReturnType => - createInputField({ label, value, size: 24 }); - -const reasonBox = (): { root: HTMLElement; say: (list: string[]) => void } => { - const root = el("div", { className: "m01-eq__why" }); - return { - root, - say: (list) => - root.replaceChildren( - ...(list.length - ? [createBanner({ tone: "danger", title: "안 됨", text: list.join(" · ") })] - : []), - ), - }; -}; - -/** 정의 줄 고침 칸 — 이름 · 기호 · 위 · 아래 · [적용] · [변수 지움] */ -export function renameEditor(d: EqDef, host: DefsHost, close: () => void): HTMLElement { - const name = field("이름", d.이름); - const sign = field("기호", d.기호); - const up = field("위첨자", d.위); - const down = field("아래첨자", d.아래); - const why = reasonBox(); - const form = (v: Pick) => ({ - 열쇠: d.열쇠, - ...v, - 단위: "", - }); - const apply = createButton({ - label: "적용", - size: 24, - onClick: async () => { - const edits = varEdits( - form(d), - form({ - 이름: name.input.value.trim(), - 기호: sign.input.value.trim(), - 위: up.input.value.trim(), - 아래: down.input.value.trim(), - }), - ); - const bad = edits.length ? await host.commit(edits) : []; - why.say(bad); - if (!bad.length) close(); - }, - }); - const drop = createButton({ - label: "변수 지움", - variant: "danger-ghost", - size: 24, - onClick: async () => { - const bad = await host.commit([{ 할일: "지움", 열쇠: d.열쇠 }]); - why.say(bad); - if (!bad.length) host.onDropped(d.열쇠); - }, - }); - const shut = createButton({ label: "닫기", variant: "ghost", size: 24, onClick: close }); - const box = el("div", { - className: "m01-eq__def-edit", - children: [ - grid(name.root, sign.root, up.root, down.root), - el("div", { className: "m01-eq__def-actions", children: [drop, shut, apply] }), - why.root, - ], - }); - box.dataset.eqDefEditor = d.기호; - return box; -} - -export interface Draft { - root: HTMLElement; - /** 연결 지정 고침 — 아직 덜 골랐으면 까닭 글 */ - read: () => EqEdit | string; - say: (why: string) => void; -} - -const KINDS = [ - { value: "입력", text: "입력 (옵션 칸에 생김)" }, - { value: "표", text: "표 찾기 (표 · 조건 · 칸)" }, - { value: "요소", text: "요소 (인력 · 재료 · 기계)" }, - { value: "로직", text: "로직 부르기" }, - { value: "상수", text: "상수 (고정 수)" }, -]; -const CATS = [ - { value: "인력", text: "인력" }, - { value: "재료", text: "재료" }, - { value: "기계", text: "기계" }, -]; - -/** 후보 찾기 — 검색 글을 칠수록 서버 후보 · 고르기 */ -function pick( - host: DefsHost, - ask: () => Record, - onPick: (c: Candidate | null) => void, - label: string, -): { root: HTMLElement; get: () => Candidate | null; refresh: () => void } { - const box = field(label); - const list = createSelectField({ - options: [{ value: "", text: "— 검색해서 고름 —" }], - size: 24, - ariaLabel: label, - }); - let found: Candidate[] = []; - let timer = 0; - const get = (): Candidate | null => found[Number(list.select.value)] ?? null; - const refresh = (): void => { - window.clearTimeout(timer); - timer = window.setTimeout(async () => { - const got = await host.links({ ...ask(), 검색: box.input.value.trim() }).catch(() => null); - found = got?.후보 ?? []; - const more = got?.더있음 ? [{ value: "", text: "… 더 있음 — 검색을 더 칠 것" }] : []; - list.setOptions( - [ - { value: "", text: found.length ? "— 고름 —" : "— 없음 —" }, - ...found.map((c, i) => ({ - value: String(i), - text: `${c.이름}${c.규격 ? ` · ${c.규격}` : ""}`, - })), - ...more, - ], - "", - ); - onPick(null); - }, 250); - }; - box.input.addEventListener("input", refresh); - list.select.addEventListener("change", () => onPick(list.select.value === "" ? null : get())); - return { - root: grid(box.root, list.root), - get: () => (list.select.value === "" ? null : get()), - refresh, - }; -} - -type Reader = () => EqEdit | string; - -const inputKind = (): { nodes: HTMLElement[]; read: Reader } => { - const base = field("기본값 (빈 칸 = 옵션에서 고름)"); - const text = (): string => base.input.value.trim(); - return { - nodes: [base.root], - read: () => ({ 할일: "연결", 갈래: "입력", ...(text() ? { 기본값: text() } : {}) }), - }; -}; - -const constKind = (): { nodes: HTMLElement[]; read: Reader } => { - const val = field("값"); - return { - nodes: [val.root], - read: () => - val.input.value.trim() - ? { 할일: "연결", 갈래: "상수", 값: val.input.value.trim() } - : "상수 값을 적을 것", - }; -}; - -const elementKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => { - const cat = createSelectField({ label: "분류", options: CATS, value: "인력", size: 24 }); - const find = pick( - host, - () => ({ 갈래: "요소", 분류: cat.select.value }), - () => undefined, - "요소 이름", - ); - cat.select.addEventListener("change", find.refresh); - find.refresh(); - return { - nodes: [cat.root, find.root], - read: () => { - const one = find.get(); - return one ? { 할일: "연결", 갈래: "요소", 요소: one.키 } : "요소를 고를 것"; - }, - }; -}; - -const logicKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => { - const sub = grid(); - const args = new Map>(); - const find = pick( - host, - () => ({ 갈래: "로직" }), - (c) => { - args.clear(); - sub.replaceChildren( - ...(c?.입력 ?? []).map((n) => { - const f = field(`${n} = (식 · 빈 칸 = 기본)`); - args.set(n, f); - return f.root; - }), - ); - }, - "로직 이름", - ); - return { - nodes: [find.root, sub], - read: () => { - const one = find.get(); - if (!one) return "부를 로직을 고를 것"; - const 인자 = Object.fromEntries([...args].map(([n, f]) => [n, f.input.value.trim()])); - return { 할일: "연결", 갈래: "로직", 로직: one.키, 인자 }; - }, - }; -}; - -const tableKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => { - const sub = grid(); - const conds = new Map>(); - const cols = createSelectField({ label: "칸", options: [{ value: "", text: "—" }], size: 24 }); - const find = pick( - host, - () => ({ 갈래: "표" }), - (c) => { - conds.clear(); - cols.setOptions( - [{ value: "", text: "— 칸 —" }, ...(c?.칸 ?? []).map((v) => ({ value: v, text: v }))], - "", - ); - sub.replaceChildren( - ...(c?.조건 ?? []).map((n) => { - const f = field(`조건 ${n} = (변수 기호 · 식)`); - conds.set(n, f); - return f.root; - }), - ); - }, - "표 이름", - ); - find.refresh(); - return { - nodes: [find.root, cols.root, sub], - read: () => { - const one = find.get(); - if (!one || !cols.select.value) return "표와 칸을 고를 것"; - const 조건 = Object.fromEntries([...conds].map(([n, f]) => [n, f.input.value.trim()])); - return { 할일: "연결", 갈래: "표", 표: one.키, 칸: cols.select.value, 조건 }; - }, - }; -}; - -/** 새 기호 줄 — 국문 이름 + 연결 고르기 · `read` 가 고침 `연결` 한 건을 줌 */ -export function draftRow(token: string, host: DefsHost): Draft { - const parts = symbolParts(token); - const name = field("국문 이름"); - const kind = createSelectField({ label: "연결", options: KINDS, value: "입력", size: 24 }); - const body = grid(); - const why = reasonBox(); - let read: Reader = () => "연결을 고를 것"; - const mount = (): void => { - const made = { 입력: inputKind, 상수: constKind }[kind.select.value as "입력" | "상수"]?.(); - const picked = - made ?? - { 요소: elementKind, 로직: logicKind, 표: tableKind }[ - kind.select.value as "요소" | "로직" | "표" - ](host); - body.replaceChildren(...picked.nodes); - read = picked.read; - }; - kind.select.addEventListener("change", mount); - mount(); - const head = el("div", { - className: "m01-eq__def-actions", - children: [ - el("span", { className: "ui-text ui-text--muted", text: `새 기호 ${token} — 연결을 지정` }), - ], - }); - const root = el("div", { - className: "ui-eq__def m01-eq__draft", - children: [head, grid(name.root, kind.root), body, why.root], - }); - root.dataset.eqDraft = token; - return { - root, - say: (text) => why.say(text ? [text] : []), - read: () => { - const made = read(); - if (typeof made === "string") return made; - const nameText = name.input.value.trim(); - return { ...made, ...parts, ...(nameText ? { 이름: nameText } : {}) }; - }, - }; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts deleted file mode 100644 index 430174187..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts +++ /dev/null @@ -1,247 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_EquationVar.ts - * 수식 탭 변수 창(PLAN 796-2 · 요구 657) — 변수 고침 · 변수 더함 · 변수 지움(쓰는 곳 있으면 막고 목록 보임). - * 고침 창 = 이름 · 기호 · 위첨자 · 아래첨자 · 단위 · 값(입력 변수만 · 시험 값 · 저장 안 함) · [적용] · [변수 지움] - * 더함 창 = 갈래(입력 · 중간) · 열쇠 · 이름 · 기호 · 첨자 · 단위 · 기본값(입력) | 식 · 자리(중간) - * 적용 = 부르는 쪽 `commit` 이 서버 미리 셈(`/logic/equation/preview`)을 거쳐 되면 [] · 안 되면 까닭 — 까닭은 창 안 띠에 그대로 - * 부품 = 모달 · 입력 · 고르기 · 단추 · 띠 알림 · 열쇠 · 식 검사 · 이름 겹침은 서버 몫 - * ========================================================================== */ - -import type { EqVarRow } from "@ui/ui_template_equation"; -import { createBanner } from "@ui/ui_template_banner"; -import { createButton } from "@ui/ui_template_button"; -import { el } from "@ui/ui_template_elements_base"; -import { createInputField, createSelectField } from "@ui/ui_template_input"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { openModal } from "@ui/ui_template_modal"; -import { addEdit, varEdits, type EqEdit, type VarForm } from "./logic/M01_Logic_Equation_Model"; - -const TEXT = { - VarTitle: ["변수 고침", "Edit variable"], - AddTitle: ["변수 더함", "Add variable"], - Close: ["닫기", "Close"], - Apply: ["적용", "Apply"], - Drop: ["변수 지움", "Delete variable"], - Key: ["열쇠 (식이 부르는 이름 · 안 바뀜)", "Key (used by formulas · fixed)"], - Name: ["이름", "Name"], - Sign: ["기호", "Symbol"], - Up: ["위첨자", "Superscript"], - Down: ["아래첨자", "Subscript"], - Unit: ["단위", "Unit"], - Value: ["값 (시험 값 · 저장 안 함)", "Value (trial · not saved)"], - Digits: ["보이는 자리 (0~6 · 빈 칸 = 전체 자리)", "Digits (0-6 · blank = full)"], - Kind: ["갈래", "Kind"], - Input: ["입력", "Input"], - Mid: ["중간", "Intermediate"], - Default: ["기본값", "Default"], - Formula: ["식", "Formula"], - After: ["자리", "Place"], - End: ["맨 끝", "End"], - Top: ["맨 위", "Top"], - Why: ["안 됨", "Not applied"], - Hint: ["이름 · 기호 · 첨자를 바꾸면 쓰는 모든 식에 함께 보임", "Renames show in every formula"], -} as const satisfies Record; -const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; - -export type Commit = (edits: EqEdit[]) => Promise; - -const grid = (...kids: HTMLElement[]): HTMLElement => - el("div", { className: "m01-eq__form", children: kids }); - -/** 까닭 띠 — 있으면 보임 · 없으면 비움 */ -function reasonBox(): { root: HTMLElement; set: (why: string[]) => void } { - const root = el("div", { className: "m01-eq__why" }); - return { - root, - set: (why) => - root.replaceChildren( - ...(why.length - ? [createBanner({ tone: "danger", title: t("Why"), text: why.join(" · ") })] - : []), - ), - }; -} - -export interface VarDialog { - row: EqVarRow; - /** 입력 변수 시험 값(옵션 칸 글) */ - value: string; - commit: Commit; - /** 시험 값을 바꿈(저장 안 함) */ - onValue: (value: string) => void; - /** 변수를 지운 뒤 — 시험 값 비움 */ - onDropped: (key: string) => void; -} - -export function openVarDialog(o: VarDialog): void { - const v = o.row; - const field = (label: string, value: string): ReturnType => - createInputField({ label, value, size: 28 }); - const name = field(t("Name"), v.이름); - const sign = field(t("Sign"), v.기호); - const up = field(t("Up"), v.위); - const down = field(t("Down"), v.아래); - const unit = field(t("Unit"), v.단위); - const val = field(t("Value"), o.value); - const shown = v.표시 === undefined ? "" : String(v.표시); - const digits = field(t("Digits"), shown); - const why = reasonBox(); - openModal({ - title: `${t("VarTitle")} · ${v.열쇠}`, - closeLabel: t("Close"), - width: "40rem", - mount: (body, close, actions) => { - body.append( - el("p", { className: "ui-text ui-text--muted", text: t("Hint") }), - grid( - name.root, - sign.root, - up.root, - down.root, - unit.root, - ...(v.갈래 === "입력" ? [val.root] : [digits.root]), - ), - why.root, - ); - actions.append( - createButton({ - label: t("Drop"), - variant: "danger-ghost", - onClick: async () => { - const bad = await o.commit([{ 할일: "지움", 열쇠: v.열쇠 }]); - why.set(bad); - if (bad.length) return; - o.onDropped(v.열쇠); - close(); - }, - }), - createButton({ - label: t("Apply"), - onClick: async () => { - const now: VarForm = { - 열쇠: v.열쇠, - 이름: name.input.value.trim(), - 기호: sign.input.value.trim(), - 위: up.input.value.trim(), - 아래: down.input.value.trim(), - 단위: unit.input.value.trim(), - 표시: digits.input.value.trim(), - }; - const edits = varEdits( - { - 열쇠: v.열쇠, - 이름: v.이름, - 기호: v.기호, - 위: v.위, - 아래: v.아래, - 단위: v.단위, - 표시: shown, - }, - now, - ); - const bad = edits.length ? await o.commit(edits) : []; - why.set(bad); - if (bad.length) return; - if (v.갈래 === "입력" && val.input.value.trim() !== o.value) - o.onValue(val.input.value.trim()); - close(); - }, - }), - ); - }, - }); -} - -export interface AddDialog { - /** 중간 줄 열쇠 차례(자리 고르기) */ - mids: string[]; - commit: Commit; -} - -export function openAddDialog(o: AddDialog): void { - const field = (label: string, value = ""): ReturnType => - createInputField({ label, value, size: 28 }); - const kind = createSelectField({ - label: t("Kind"), - options: [ - { value: "입력", text: t("Input") }, - { value: "중간", text: t("Mid") }, - ], - value: "입력", - size: 28, - }); - const key = field(t("Key")); - const name = field(t("Name")); - const sign = field(t("Sign")); - const up = field(t("Up")); - const down = field(t("Down")); - const unit = field(t("Unit")); - const dflt = field(t("Default")); - const expr = field(t("Formula")); - const place = createSelectField({ - label: t("After"), - options: [ - { value: "", text: t("End") }, - { value: "\u0000top", text: t("Top") }, - ...o.mids.map((m) => ({ value: m, text: m })), - ], - value: "", - size: 28, - }); - const why = reasonBox(); - const sync = (): void => { - const mid = kind.select.value === "중간"; - dflt.root.hidden = mid; - expr.root.hidden = place.root.hidden = !mid; - }; - kind.select.addEventListener("change", sync); - sync(); - openModal({ - title: t("AddTitle"), - closeLabel: t("Close"), - width: "40rem", - mount: (body, close, actions) => { - body.append( - grid( - kind.root, - key.root, - name.root, - sign.root, - up.root, - down.root, - unit.root, - dflt.root, - expr.root, - place.root, - ), - why.root, - ); - actions.append( - createButton({ - label: t("Apply"), - onClick: async () => { - const mid = kind.select.value === "중간"; - const at = place.select.value; - const edit = addEdit( - { - 열쇠: key.input.value, - 이름: name.input.value, - 기호: sign.input.value, - 위: up.input.value, - 아래: down.input.value, - 단위: unit.input.value, - 갈래: mid ? "중간" : "입력", - 기본값: dflt.input.value.trim(), - 식: expr.input.value.trim(), - }, - at === "\u0000top" ? "" : at || undefined, - ); - const bad = await o.commit([edit]); - why.set(bad); - if (!bad.length) close(); - }, - }), - ); - }, - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 7e56c29d3..8446e0132 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -178,7 +178,6 @@ } /* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김 */ -.m01-logic__editor[data-view="equation"] [data-block]:not([data-block="equation"]), .m01-logic__editor[data-view="stack"] [data-block]:not([data-block="stack"]), .m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) { display: none; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts index cdac143e4..3c0691704 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_View.ts * 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」 - * 보기 셋 = 수식(기본 · 796-2) · 줄 쌓기(804-2 · `A00_Common/line_stack_screen.ts`) · 단가산출 상세 — 고른 덩이만 보임(시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 799-2 에 걷음) + * 보기 둘 = 수식(기본 · 줄 쌓기 화면 `A00_Common/line_stack_screen.ts` · 804-6 옛 수식 문서 탭 걷음) · 단가산출 상세 — 고른 덩이만 보임 * 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지 * 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위) * 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식) @@ -9,10 +9,9 @@ import { createSegment } from "@ui/ui_template_segment"; import { createToolbar } from "@ui/ui_template_toolbar"; -import { lt } from "../A00_Common/line_stack_text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -export type LabView = "equation" | "stack" | "ho"; +export type LabView = "stack" | "ho"; /** 덩이에 보기 이름표를 붙여 돌려줌 */ export const block = (node: T, view: LabView): T => { @@ -25,14 +24,13 @@ export function createViewPicker( host: HTMLElement, onChange?: (v: LabView) => void, ): { root: HTMLElement } { - host.dataset.view = "equation"; + host.dataset.view = "stack"; const segment = createSegment({ options: [ - { value: "equation", label: tl("View_Equation") }, - { value: "stack", label: lt("Tab") }, + { value: "stack", label: tl("View_Equation") }, { value: "ho", label: tl("Ho_Title") }, ], - value: "equation", + value: "stack", size: 32, ariaLabel: tl("View_Label"), onChange: (v) => { diff --git a/M01_MasterData/logic/M01_Logic_Equation_Model.ts b/M01_MasterData/logic/M01_Logic_Equation_Model.ts deleted file mode 100644 index 7df79333f..000000000 --- a/M01_MasterData/logic/M01_Logic_Equation_Model.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* ============================================================================= - * M01_MasterData/logic/M01_Logic_Equation_Model.ts - * 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청. - * 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦 - * 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만 - * ========================================================================== */ - -import type { EqLine } from "@ui/ui_template_equation"; - -export interface EqEdit { - 할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결"; - [key: string]: unknown; -} - -/** 식 줄의 고침 자리(`중간:<열쇠>` · `결과` · `호표:` · `덧줄:`) */ -export const spotOf = (line: EqLine): string => - line.갈래 === "호표" || line.갈래 === "덧줄" - ? `${line.갈래}:${line.차례 ?? 0}` - : line.갈래 === "결과" - ? "결과" - : `중간:${line.열쇠}`; - -export const formulaEdit = (line: EqLine, text: string): EqEdit => ({ - 할일: "식", - 자리: spotOf(line), - 식: text, -}); - -/** 아이콘 줄 — 식 칸 글에 끼우는 꼴 */ -export type InsertKind = "frac" | "paren" | "sub" | "sup" | "percent"; - -/** 선택 영역 `[from, to)` 를 아이콘 꼴로 바꾼 글 · 커서 자리 */ -export function insertInto( - kind: InsertKind, - text: string, - from: number, - to: number, -): { text: string; caret: number } { - const head = text.slice(0, from); - const pick = text.slice(from, to); - const tail = text.slice(to); - const put = (mid: string, caret: number): { text: string; caret: number } => ({ - text: head + mid + tail, - caret: head.length + caret, - }); - switch (kind) { - case "frac": // 분수 = (위) / (아래) — 고른 글이 위 · 커서는 아래 괄호 안 - return pick ? put(`(${pick}) / ()`, pick.length + 6) : put("() / ()", 1); - case "paren": - return pick ? put(`(${pick})`, pick.length + 2) : put("()", 1); - case "sub": - return put(`${pick}_`, pick.length + 1); - case "sup": - return put(`${pick}^`, pick.length + 1); - case "percent": - return put(`${pick} / 100`, pick.length + 6); - } -} - -/** 옵션 값 — 수 꼴 글은 수로(엔진 입력 꼴) · 빈 값은 안 보냄(자동값) */ -export function inputsOf(values: Record): Record { - const out: Record = {}; - for (const [name, raw] of Object.entries(values)) { - if (raw === "") continue; - out[name] = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw; - } - return out; -} - -export interface VarForm { - 열쇠: string; - 이름: string; - 기호: string; - 위: string; - 아래: string; - 단위: string; - 갈래?: "입력" | "중간"; - 설명?: string; - 기본값?: string; - 식?: string; - /** 보이는 자리 글(중간 줄 · 빈 글 = 전체) */ - 표시?: string; -} - -/** 변수 고침 창 → 요청 목록(바꾼 칸만) — `보이는 자리`(중간 줄) = 0~6 · 빈 칸 = 전체 자리(`표시` 지움) */ -export function varEdits(before: VarForm, now: VarForm): EqEdit[] { - const out: EqEdit = { 할일: "변수", 열쇠: before.열쇠 }; - let changed = false; - for (const k of ["이름", "기호", "위", "아래", "단위"] as const) - if (now[k] !== before[k]) { - out[k] = now[k]; - changed = true; - } - const list: EqEdit[] = changed ? [out] : []; - if (now.표시 !== before.표시) { - const digits = now.표시 === "" || now.표시 === undefined ? null : Number(now.표시); - list.push({ 할일: "표시", 열쇠: before.열쇠, 표시: digits }); - } - return list; -} - -/** 변수 더함 창 → 요청 — 빈 칸은 안 보냄 */ -export function addEdit(form: VarForm, after?: string): EqEdit { - const out: EqEdit = { 할일: "더함", 갈래: form.갈래 ?? "입력", 열쇠: form.열쇠.trim() }; - for (const k of ["이름", "기호", "위", "아래", "단위", "설명"] as const) { - const v = (form[k] ?? "").trim(); - if (v) out[k] = v; - } - if (form.갈래 === "중간") { - out.식 = form.식 || "0"; - if (after !== undefined) out.뒤 = after; - } else if (form.기본값) out.기본값 = form.기본값; - return out; -} - -/** 서버 까닭 「… · 모르는 기호 「A」 「B」 — …」 → 새 기호 글 목록(`master_equation_edit._set_formula` 가 같은 글을 냄) */ -export function unknownOf(reasons: string[]): string[] { - for (const why of reasons) { - const hit = /모르는 기호((?:\s*「[^」]+」)+)/.exec(why); - if (hit?.[1]) return [...hit[1].matchAll(/「([^」]+)」/g)].map((m) => m[1] ?? ""); - } - return []; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index 9e076a896..9d645f1b0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -1,22 +1,19 @@ /* ============================================================================= * M02_MasterTemplete_Sangeun_Tabs.ts - * M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 802-4b-2 · 804-2 · 요구 655-8 · 660~665 · 677) — 수식(첫 탭) · 줄 쌓기 · 단가산출 상세. - * 줄 쌓기 = 공용 줄 쌓기 화면(`A00_Common/line_stack_screen.ts`) — 같은 SK 수식 길 · 같은 캐시 · 탭을 바꾸면 고른 쪽이 다시 읽음 - * 수식 = 공용 수식 문서 화면(`A00_Common/formula_doc_screen.ts` · M01 과 같은 꼴) — 길 앞머리만 M02 사본 길 + * M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 804-2 · 804-6 · 요구 686-1) — 수식(첫 탭 · 줄 쌓기) · 단가산출 상세. + * 수식 = 공용 줄 쌓기 화면(`A00_Common/line_stack_screen.ts` · M01 과 같은 꼴) — 길 앞머리만 M02 사본 길 * (`/m02/layers/{층}/sangeun/{키}/doc` · 802-4a) · 층 쓰기 권한 없으면 읽기만 · 저장 = 그 탭 [수식 저장](페이지 [저장] 아님) - * 옛 M02 수식 나무 화면(796-2b · 2c · 2e · 800 · 801)은 걷음 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김) + * 옛 수식 문서 탭(802-4b-2)은 804-6 에 걷음 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김) * 저장 안 한 SK(일위대가 줄 모달 · 새 SK) = 「저장한 단가산출만」 글 * ========================================================================== */ import { createSegment } from "@ui/ui_template_segment"; -import { createDocScreen } from "../A00_Common/formula_doc_screen"; import { createStackScreen } from "../A00_Common/line_stack_screen"; -import { lt } from "../A00_Common/line_stack_text"; import { el } from "@ui/ui_template_elements"; import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; -type View = "equation" | "stack" | "ho"; +type View = "stack" | "ho"; export interface SkEquationOptions { scope: { layer: Layer; owner: string | null }; @@ -46,12 +43,6 @@ export function createSangeunTabs( draw: (key: string) => void; canLeave: () => Promise; } { - const screen = createDocScreen({ - base: "", - admin: opt.edit, - cache: "m02_sk_doc_drafts", - copy: { route: (k, p) => skDocPath(opt.scope, k, p), onSaved: opt.onSaved }, - }); const stack = createStackScreen({ base: "", admin: opt.edit, @@ -60,42 +51,35 @@ export function createSangeunTabs( scope: opt.scope, // 804-4 다른 단가산출 · 일위대가 복사 }); let key = ""; - let view: View = "equation"; + let view: View = "stack"; const show = (v: View): void => { view = v; - screen.root.hidden = v !== "equation"; stack.root.hidden = v !== "stack"; editor.hidden = v !== "ho"; if (!editor.hidden) editor.dataset.view = v; }; const picker = createSegment({ options: [ - { value: "equation", label: tl("View_Equation") }, - { value: "stack", label: lt("Tab") }, + { value: "stack", label: tl("View_Equation") }, { value: "ho", label: tl("Ho_Title") }, ], - value: "equation", + value: "stack", size: 32, ariaLabel: tl("View_Label"), onChange: (v) => { show(v as View); if (v === "stack") stack.draw(key, true); - else if (v === "equation" && key) { - screen.draw(""); - screen.draw(key); - } }, }); picker.root.dataset.skViews = ""; - show("equation"); + show("stack"); return { picker: picker.root, - host: el("div", { className: "m02-sk__views", children: [screen.root, stack.root] }), + host: el("div", { className: "m02-sk__views", children: [stack.root] }), draw: (k: string) => { key = k; - screen.draw(k); if (view === "stack") stack.draw(k); }, - canLeave: () => screen.canLeave().then((ok) => ok && stack.canLeave()), + canLeave: () => stack.canLeave(), }; } diff --git a/resources/tester/test_796_2_equation_screen.py b/resources/tester/test_796_2_equation_screen.py deleted file mode 100644 index 3267a172b..000000000 --- a/resources/tester/test_796_2_equation_screen.py +++ /dev/null @@ -1,139 +0,0 @@ -"""796-2 수식 탭 화면 지킴(정적 · 서버 · 브라우저 없이) — 부품 상자만 · 수식 탭 첫 탭 · 아이콘 숨김 · 변수 고침 창 · 옵션 가로. - -화면 동작(분수 · 변수 풀이 · 고침 → 금액 · 변수 더함 · 지움 · 저장)은 ORCA 사용자 길로 확인 — 서버 모델은 `test_796_1_formula_model.py`. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -M01 = ROOT / "M01_MasterData" -UI = ROOT / "ui_template" -PARTS = [UI / "ui_template_equation.ts", UI / "ui_template_equation_line.ts"] -PAGE = [ - M01 / "M01_MasterData_UI_LogicLab_Equation.ts", - M01 / "M01_MasterData_UI_LogicLab_EquationVar.ts", -] -MODEL = M01 / "logic" / "M01_Logic_Equation_Model.ts" -CSS = [UI / "ui_template_equation.css", M01 / "M01_MasterData_UI_LogicLab_Equation.css"] -RAW = re.compile(r" str: - return path.read_text(encoding="utf-8").replace("\r\n", "\n") - - -def test_부품_상자만_날_단추_입력_없음(): - for path in [*PARTS, *PAGE, MODEL]: - src = _text(path) - assert not RAW.search(src), f"{path.name} — 날 button · input" - # 수식 칩 = 글 속 부품(span role) · 단추 · 입력 · 고르기는 부품 상자 것 - page = _text(PAGE[0]) + _text(PAGE[1]) - for part in ( - "createButton", - "createToolbar", - "createFormulaCell", - "openModal", - ): - assert part in page, part - assert "createValueControl" in page # 옵션 값 칸 = 기존 값 칸 한 벌(M02_Labor_Values) - - -def test_색은_공통_값만_700줄_안(): - for path in CSS: - css = _text(path) - assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(", css), path.name - assert "var(--color-" in css - for path in [*PARTS, *PAGE, MODEL, *CSS, M01 / "M01_MasterData_UI_LogicLab.ts"]: - assert len(_text(path).splitlines()) <= 700, path.name - assert len(_text(M01 / "M01_MasterData_UI_LogicLab_Ho.ts").splitlines()) <= 700 - - -def test_계산_자리_화면은_모양만(): - model = _text(MODEL) - assert "fetch(" not in model and "document." not in model # logic/ = 화면 · fetch 없음 - for path in PARTS: - assert "fetch(" not in _text(path) # 부품은 그리기만 - page = _text(PAGE[0]) - assert page.count("fetch(") == 1 # 서버 길 한 곳 — 계산은 서버 한 벌(③) - for route in ("`/logic/equation${suffix}`", '"/preview"', '"/logic/equation/save"'): - assert route in page, route # 796-2b 길 앞머리 = M01 · 페이지 갈래(page.ask) - assert "page ? page.ask(suffix, body, signal)" in page - assert "saveRevised(" in page and "개정확인" in page # [저장] = 개정 확인 길 - assert "edits" in page and "sessionStorage" in page # 고침 목록 = 캐시 → [저장] 한 번 - assert "ctl?.abort()" in page # 옛 요청 끊음 - assert "setInterval" not in page and "autosave" not in page.lower() # 자동저장 없음 - - -def test_수식_탭이_첫_탭_단가산출_상세는_그대로(): - """799-2 — 보기 둘(수식 · 단가산출 상세)만 남김(시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 걷음).""" - view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts") - labels = re.findall(r'\{ value: "(\w+)", label: tl\("(\w+)"\) \}', view) - assert [v for v, _ in labels] == ["equation", "ho"] - assert 'host.dataset.view = "equation";' in view and 'value: "equation",\n size: 32' in view - detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts") - assert 'block(ctx.equation, "equation")' in detail - assert '"formula"' not in detail and "buildFormula" not in detail # 799-2 텍스트 수식 걷음 - css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css") - assert '[data-view="equation"] [data-block]:not([data-block="equation"])' in css - lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts") - assert "createLabEquation({" in lab and "equation: equation.root," in lab - assert "equation.canLeave()" in lab # 저장 전 떠나면 버림 물음 - - -def test_식_바로_고침은_관리자만(): - # 800-3 — 줄을 눌러 여는 아래 편집 · 아이콘 줄을 걷고 식 칸을 그 자리에서 고침 - line = _text(PARTS[1]) - assert "o.selected" not in line and "o.slot" not in line and "onSelect" not in line - assert "o.editing === id && o.editor" in line # 이름 식 칸 자리에 식 칸 - page = _text(PAGE[0]) - for icon in ('"frac"', '"paren"', '"sub"', '"sup"'): - assert icon not in page, icon # 아이콘 줄 걷음 - assert 'label: t("Add")' in page # 변수 더함 - assert "opt.admin && !opt.blocked()" in page # 관리자 + 시트 쪽 저장 안 한 고침 없을 때만 - assert "onEdit: edit" in page and "editor: edit" in page - assert "createFormulaCell" in page and "../A00_Common/sheet_formula_cell" in page - - -def test_변수_고침_창_칸과_지움_막힘(): - var = _text(PAGE[1]) - for label in ( - 'Name: ["이름"', - 'Sign: ["기호"', - 'Up: ["위첨자"', - 'Down: ["아래첨자"', - 'Unit: ["단위"', - 'Digits: ["보이는 자리', - "Value:", - ): - assert label in var, label - assert '할일: "지움"' in var and "why.set(bad)" in var # 쓰는 곳 목록 = 서버 까닭 그대로 띠에 - assert "commit(" in var or "o.commit(" in var - assert "openAddDialog" in var and 'value: "중간"' in var # 입력 · 중간 더함 - model = _text(MODEL) - for job in ('"식"', '"더함"', '"지움"', '"변수"'): - assert job in model, job - - -def test_옵션_가로_찾기형_말줄임(): - page = _text(PAGE[0]) - assert "createToolbar({" in page and "optionNode(" in page - assert "BIG = 12" in page and "datalist" in page # 고르기 많으면 찾기형 - assert "title" in page # 마우스 올림 전체 글 - css = _text(CSS[1]) - assert "text-overflow: ellipsis" in css and "max-width: 14rem" in css - assert "askAgain" in page # 옵션 바꾸면 다시 셈 - - -def test_부품_모음_견본_한_줄(): - src = _text(UI / "ui_template_ui_kit_parts.ts") - rows = re.findall( - r'\{ key: "([^"]+)", name: "[^"]+", group: "(\w+)",[^\n]*file: "([^"]+)"', src - ) - assert ("equation", "compound", "ui_template_equation.ts") in rows - assert len(src.splitlines()) <= 700 - head = _text(PARTS[0]) - for word in ("분수", "첨자", "조건 칩", "크기", "상태", "쓰는 법"): - assert word in head, word diff --git a/resources/tester/test_799_1_subtotals.py b/resources/tester/test_799_1_subtotals.py index 7b012419a..620694717 100644 --- a/resources/tester/test_799_1_subtotals.py +++ b/resources/tester/test_799_1_subtotals.py @@ -98,5 +98,3 @@ def test_화면_부품이_서버_칸을_읽음(): ts = Path("ui_template/ui_template_equation_sum.ts").read_text(encoding="utf-8") for name in ("노무비", "재료비", "경비", "끝수", "합계"): assert name in ts - line = Path("M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8") - assert "createEqSum(eq, open" in line # 796-2e 덧단추 자리 인자가 뒤에 붙음 diff --git a/resources/tester/test_799_2_old_tabs_gone.py b/resources/tester/test_799_2_old_tabs_gone.py index 31539043c..8fe3201cd 100644 --- a/resources/tester/test_799_2_old_tabs_gone.py +++ b/resources/tester/test_799_2_old_tabs_gone.py @@ -60,8 +60,8 @@ def test_보기는_수식_단가산출상세_둘(): m01 = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts") m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Tabs.ts") for src in (m01, m02): - assert re.findall(r'\{ value: "(\w+)", label: ', src) == ["equation", "ho"] - assert 'export type LabView = "equation" | "ho";' in m01 + assert re.findall(r'\{ value: "(\w+)", label: ', src) == ["stack", "ho"] # 804-6 + assert 'export type LabView = "stack" | "ho";' in m01 css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css") assert '[data-view="ho"] [data-block]:not([data-block="ho"])' in css assert 'data-view="sheet"' not in css and 'data-view="calc"' not in css diff --git a/resources/tester/test_800_2_equation_layout.py b/resources/tester/test_800_2_equation_layout.py index 127774086..c96badf8d 100644 --- a/resources/tester/test_800_2_equation_layout.py +++ b/resources/tester/test_800_2_equation_layout.py @@ -121,10 +121,7 @@ def test_screen_source_groups_and_inline_edit(): ui = ROOT / "ui_template" sum_ts = (ui / "ui_template_equation_sum.ts").read_text(encoding="utf-8") line_ts = (ui / "ui_template_equation_line.ts").read_text(encoding="utf-8") - page = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text( - encoding="utf-8" - ) assert "ORDER" in sum_ts and "ui-eq__grp" in sum_ts # 가 · 나 · 다 묶음 - assert "o.after?.(line)" in line_ts and "after:" in page # 블록 식 줄 바로 아래 + assert "o.after?.(line)" in line_ts # 블록 식 줄 바로 아래 assert "block-key" not in line_ts # 줄 곁 DB 열쇠 글 없음 assert "ui-eq__solve" not in line_ts and "ui-eq__step" not in line_ts # 풀이 · 줄 번호 걷음 diff --git a/resources/tester/test_802_4b2_m02_screen.py b/resources/tester/test_802_4b2_m02_screen.py index f97bd985a..32be01347 100644 --- a/resources/tester/test_802_4b2_m02_screen.py +++ b/resources/tester/test_802_4b2_m02_screen.py @@ -48,10 +48,10 @@ def _src(rel: str) -> str: def test_길_앞머리_M02_공용화면(): tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") - assert "createDocScreen({" in tabs and "copy: { route: (k, p) => skDocPath(" in tabs + assert "createStackScreen({" in tabs and "copy: { route: (k, p) => skDocPath(" in tabs assert "/m02/layers/${scope.layer}/sangeun/${encodeURIComponent(key)}/doc${tail}" in tabs assert "admin: opt.edit" in tabs # 층 쓰기 권한 = 고침 - scr = _src("A00_Common/formula_doc_screen.ts") + scr = _src("A00_Common/line_stack_screen.ts") # 804-6 수식 탭 = 줄 쌓기 assert "cp ? cp.route(key, path) : opt.base + path" in scr assert "{ 문서: doc, 끊음: cut, 판: version }" in scr # 저장 몸 detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") @@ -59,7 +59,7 @@ def test_길_앞머리_M02_공용화면(): assert not (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts").exists() main = _src("main.py") assert '__import__("M02_MasterTemplete.M02_Doc_Screen", fromlist=["router"]).router' in main - for name in ("formula_doc_screen", "formula_doc_sub", "formula_doc_copy", "formula_doc_edit"): + for name in ("line_stack_screen", "formula_doc_copy"): assert len(_src(f"A00_Common/{name}.ts").splitlines()) <= 700, name @@ -153,7 +153,7 @@ def test_권한_회사층_일반계정_읽기만(world): ) assert any(c["키"] == "GC000267" for c in found["후보"]) assert "줄" in screen.doc_table("company", key, "QC000431", None, _session()) - scr = _src("A00_Common/formula_doc_screen.ts") + scr = _src("A00_Common/line_stack_screen.ts") assert "buildOptions(doc, given, !!cp && !opt.admin," in scr # 사본 읽기만 = 옵션 잠금 copy_src = _src("A00_Common/formula_doc_copy.ts") assert "if (!edit) return [];" in copy_src and 'createBadge(ft("Read_Only")' in copy_src diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py index b625e292c..8f36954c1 100644 --- a/resources/tester/test_804_2_line_stack.py +++ b/resources/tester/test_804_2_line_stack.py @@ -209,16 +209,13 @@ def _src(rel: str) -> str: def test_화면_이음(): lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") assert 'createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" })' in lab - assert ( - "stack: stack.root" in lab - and "equation.canLeave().then((ok) => ok && stack.canLeave())" in lab - ) - assert '{ value: "stack", label: lt("Tab") }' in _src( + assert "stack: stack.root" in lab and "=> stack.canLeave();" in lab # 804-6 수식 탭 = 줄 쌓기 + assert '{ value: "stack", label: tl("View_Equation") }' in _src( "M01_MasterData/M01_MasterData_UI_LogicLab_View.ts" ) assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css") tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") - assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 2 + assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 1 vite = _src("vite.config.ts") assert 'exclude: ["0_old", "katex"]' in vite and "katex/dist/katex.mjs" in vite pkg = json.loads(_src("config/package.json")) diff --git a/resources/tester/test_804_6_formula_tab_gone.py b/resources/tester/test_804_6_formula_tab_gone.py new file mode 100644 index 000000000..07da9c42b --- /dev/null +++ b/resources/tester/test_804_6_formula_tab_gone.py @@ -0,0 +1,70 @@ +"""804-6(요구 686-1) — 옛 수식 문서 탭 걷음 · 줄 쌓기 = 첫 「수식」 탭 지킴. + +① 걷은 파일 없고 부르는 곳 없음 ② M01 · M02 보기 = 수식(줄 쌓기) · 단가산출 상세 +③ 줄 쌓기가 사본 [단가 갱신] · [재생성] · 판 바뀜 다시 저장을 이어 받음. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +GONE = [ + "A00_Common/formula_doc_screen.ts", + "A00_Common/formula_doc_defs.ts", + "A00_Common/formula_doc_edit.ts", + "A00_Common/formula_doc_lines.ts", + "A00_Common/formula_doc_math.ts", + "A00_Common/formula_doc_math_model.ts", + "A00_Common/formula_doc_rows.ts", + "A00_Common/formula_doc_sub.ts", + "A00_Common/formula_doc.css", + "A00_Common/sheet_formula_cell.ts", + "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts", + "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css", + "M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts", + "M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts", + "M01_MasterData/logic/M01_Logic_Equation_Model.ts", + "ui_template/ui_template_equation_defs.ts", + "ui_template/ui_template_equation_edit.ts", +] + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_걷은_파일_없고_부르는_곳_없음(): + assert not [g for g in GONE if (ROOT / g).exists()] + names = [Path(g).name.rsplit(".", 1)[0] for g in GONE] + out = subprocess.run( + ["git", "-c", "core.quotepath=off", "ls-files", "*.ts", "*.css", "*.html"], + cwd=ROOT, capture_output=True, text=True, encoding="utf-8", check=True, + ).stdout # fmt: skip + for rel in out.splitlines(): + if rel.startswith(("docs/", "tmp/")) or not (ROOT / rel).exists(): + continue + text = (ROOT / rel).read_text(encoding="utf-8", errors="ignore") + for n in names: + assert f'/{n}"' not in text and f"{n}.css" not in text, (rel, n) + + +def test_보기_수식은_줄쌓기_첫탭(): + for rel in ( + "M01_MasterData/M01_MasterData_UI_LogicLab_View.ts", + "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts", + ): + src = _text(rel) + assert '{ value: "stack", label: tl("View_Equation") },' in src, rel + assert 'value: "stack",\n' in src and '"equation"' not in src, rel + lab = _text("M01_MasterData/M01_MasterData_UI_LogicLab.ts") + assert "createStackScreen({" in lab and "createDocScreen" not in lab + + +def test_줄쌓기가_사본_할일_이어받음(): + src = _text("A00_Common/line_stack_screen.ts") + assert "copyTools(true, (job) => void runJob(job))" in src + assert 'call("/preview", { 할일: job })' in src + assert "e.status !== 409" in src and "#끊음" not in src + assert len(src.splitlines()) <= 700 diff --git a/ui_template/ui_template_equation_defs.ts b/ui_template/ui_template_equation_defs.ts deleted file mode 100644 index 6c5acb736..000000000 --- a/ui_template/ui_template_equation_defs.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* ============================================================================= - * ui_template_equation_defs.ts - * 부품 「수식」 넷째 파일(800-2 · 요구 660-2 · 3 · 6) — 오른쪽 「변수 정의 영역」. - * 한 덩어리 목록 · 같은 기호는 한 줄(서버가 합침) · 줄 = 기호 : 국문 이름 — 연결 설명(입력 · 표 찾기 · 요소 · 로직 · 계산을 사람 말로) - * 값 · 단위 · DB 열쇠 글은 안 그림(값은 왼쪽 값 식에 있음) · 고른 옵션 갈래의 변수만(서버 `정의`) - * 상태 = 고침 열린 줄 `data-editing`(줄 아래 `editor` 칸) · 연결 안 한 새 기호 줄 `data-draft`(`drafts` 요소 — 부르는 쪽이 만듦) · 눌러 보인 줄 `data-flash` - * 쓰는 법 = `createEqDefs(defs, { editing, onEdit, editor, drafts, head })` → 요소 · `flashEqDef(root, node)` = 식의 변수를 눌렀을 때 그 정의 줄로 이동 - * ========================================================================== */ - -import { symbolOf, type EqDef, type EqNode } from "./ui_template_equation"; - -export interface EqDefsOptions { - /** 고침이 열린 정의의 기호 글(`defSign`) */ - editing?: string | null; - /** 정의 줄을 누름(없으면 고칠 수 없음 — 고침 가능한 줄만 눌림) */ - onEdit?: (def: EqDef) => void; - /** 고침이 열린 줄 아래 칸 */ - editor?: (def: EqDef) => HTMLElement; - /** 연결을 지정할 새 기호 줄들 */ - drafts?: HTMLElement[]; - /** 머리 오른쪽 단추 자리 */ - head?: HTMLElement[]; -} - -const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => { - const out = document.createElement(tag); - out.className = className; - if (text) out.textContent = text; - return out; -}; - -/** 정의의 기호 글(기호 · 아래 · 위) — 줄 이름표 · 식의 변수와 맞대는 열쇠 */ -export const defSign = (d: Pick): string => - `${d.기호}${d.아래 ? `_${d.아래}` : ""}${d.위 ? `^${d.위}` : ""}`; - -/** 식 변수 마디의 기호 글 — `defSign` 과 같은 꼴 */ -export const nodeSign = (n: Pick): string => - defSign({ - 기호: n.기호 || n.열쇠 || "", - 위: typeof n.위 === "string" ? n.위 : "", - 아래: typeof n.아래 === "string" ? n.아래 : "", - }); - -function defRow(d: EqDef, o: EqDefsOptions): HTMLElement { - const sign = defSign(d); - const row = node("div", "ui-eq__def"); - row.dataset.eqDef = sign; - row.dataset.kind = d.갈래; - const main = node("div", "ui-eq__def-main"); - main.append(symbolOf({ 열쇠: d.열쇠, 기호: d.기호, 위: d.위, 아래: d.아래 })); - if (d.이름 && d.이름 !== d.기호) - main.append(node("span", "ui-eq__op", ":"), node("span", "ui-eq__def-name", d.이름)); - if (d.안씀) main.append(node("span", "ui-eq__def-tag", "쓰는 식 없음")); - const links = node("div", "ui-eq__def-links"); - for (const text of d.연결) links.append(node("div", "ui-eq__def-link", text)); - const top = node("div", "ui-eq__def-top", ""); - top.append(main, links); - if (o.onEdit && d.고침) { - row.classList.add("is-pick"); - top.title = "눌러서 이름 · 기호 · 첨자 고침 · 지움"; - top.addEventListener("click", () => o.onEdit?.(d)); - } - row.append(top); - if (o.editing === sign && o.editor) { - row.dataset.editing = "1"; - row.append(o.editor(d)); - } - return row; -} - -export function createEqDefs(defs: EqDef[], o: EqDefsOptions = {}): HTMLElement { - const root = node("div", "ui-eq__defs"); - root.dataset.eqDefs = ""; - const head = node("div", "ui-eq__defs-head"); - head.append(node("span", "ui-eq__defs-title", "변수 정의"), ...(o.head ?? [])); - root.append(head); - for (const draft of o.drafts ?? []) root.append(draft); // 새 기호는 맨 위 — 식을 고치는 곳 옆에서 바로 보이게 - for (const d of defs) root.append(defRow(d, o)); - if (!defs.length && !o.drafts?.length) root.append(node("div", "ui-eq__def-empty", "—")); - return root; -} - -/** 식의 변수를 눌렀을 때 — 그 정의 줄로 이동 · 잠깐 표시 */ -export function flashEqDef(root: HTMLElement, n: EqNode): void { - const sign = nodeSign(n); - const row = [...root.querySelectorAll("[data-eq-def]")].find( - (r) => r.dataset.eqDef === sign, - ); - if (!row) return; - row.scrollIntoView({ block: "nearest", behavior: "smooth" }); - row.dataset.flash = "1"; - window.setTimeout(() => delete row.dataset.flash, 1200); -} diff --git a/ui_template/ui_template_equation_edit.ts b/ui_template/ui_template_equation_edit.ts deleted file mode 100644 index 08cbcc11e..000000000 --- a/ui_template/ui_template_equation_edit.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* ============================================================================= - * 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); -}