From f5545dd62b4573583ef23c95921b1722c9ee9d03 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 21:07:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(803-2a):=20=EC=88=98=EC=8B=9D=20=ED=83=AD?= =?UTF-8?q?=20=EC=84=B8=20=EC=98=81=EC=97=AD=20=C2=B7=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EC=9D=98=20=EC=88=98=EC=8B=9D=20=EC=98=81=EC=97=AD=20=C2=B7=20?= =?UTF-8?q?=ED=95=A9=EA=B3=84=20=EC=83=81=EC=9E=90=20=C2=B7=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EA=B8=80=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세 영역 가로 — 기호 식(줄바꿈으로 이어진 하나의 수식 영역) | 값 식(같은 높이 줄) | 기호 정의 - 맨 아래 합계 상자 = 단가산출 상세 탭 부품(노무비 · 재료비 · 경비 · 계) · 옵션 아래 계 줄 · 설명 글 · 표지 안내 줄 걷음(오류 = 그 기호 빨강) - 기호 식 줄 누름 = 그 자리 편집기(치는 대로 조판 — / 분수 · _ 아래첨자 · ^ 위첨자 · 괄호 · 화살표 구조 이동 · Enter 새 줄 · Esc 나옴 · 한글 조합) · 편집 중만 영역 위 수식 도구 줄 - 금액 줄 = 영역 안 식 줄 「노무비 형틀목공 = w × Q」(머리 첫 말 = 비목) · 「+ 식 줄」 「+ 금액 줄」 「+ 묶음」 · 비목 고르기 칸 걷음 · 묶음 = 도구 줄 [묶음] - 저장 전 새 로직 = 세 영역 빈 화면 · 끊은 단가산출 블록도 같은 편집기 - 새 라이브러리 없음 · 서버 길 · 계산기 · 문서 꼴 그대로 Co-Authored-By: Claude Opus 5.5 --- A00_Common/formula_doc.css | 215 ++++++++++----- A00_Common/formula_doc_edit.ts | 64 ++++- A00_Common/formula_doc_foot.ts | 20 ++ A00_Common/formula_doc_lines.ts | 273 ++++++++----------- A00_Common/formula_doc_math.ts | 380 +++++++++++++++++++++++++++ A00_Common/formula_doc_math_model.ts | 259 ++++++++++++++++++ A00_Common/formula_doc_rows.ts | 199 ++++++++++++++ A00_Common/formula_doc_screen.ts | 235 ++++++++--------- A00_Common/formula_doc_sub.ts | 72 +++-- A00_Common/formula_doc_text.ts | 29 +- 10 files changed, 1341 insertions(+), 405 deletions(-) create mode 100644 A00_Common/formula_doc_foot.ts create mode 100644 A00_Common/formula_doc_math.ts create mode 100644 A00_Common/formula_doc_math_model.ts create mode 100644 A00_Common/formula_doc_rows.ts diff --git a/A00_Common/formula_doc.css b/A00_Common/formula_doc.css index 5d8fef9df..a2c3a59ec 100644 --- a/A00_Common/formula_doc.css +++ b/A00_Common/formula_doc.css @@ -1,4 +1,4 @@ -/* 수식 문서 화면(802-3) — 왼쪽 기호 식 · 오른쪽 값 식 · 오른쪽 아래 기호 정의 · 색은 공통 값만 */ +/* 수식 문서 화면(802-3 · 803-2a) — 세 영역 가로(기호 식 수식 영역 | 값 식 | 기호 정의) · 맨 아래 합계 상자 · 색은 공통 값만 */ .fdoc { gap: var(--spacing-12); } @@ -37,39 +37,84 @@ gap: var(--spacing-8); } -.fdoc__headval { - font-size: var(--text-subheading); - font-variant-numeric: tabular-nums; -} - .fdoc__resultsym { width: 9em; } -.fdoc__hint { - margin: 0; +/* 세 영역 — 기호 식 · 값 식(격자 두 칸 · 줄마다 같은 높이) | 기호 정의 */ +.fdoc__areas { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: var(--spacing-24); + align-items: start; } -.fdoc__marks { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - align-items: center; -} - -.fdoc__marks:empty { - display: none; -} - -/* 격자 — 왼쪽 기호 식 · 오른쪽 값 식(줄마다 같은 높이) */ .fdoc__grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: var(--spacing-4) var(--spacing-24); + 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); @@ -184,21 +229,7 @@ font-weight: var(--font-weight-medium, 500); } -.fdoc__kind { - width: 6.5em; -} - -.fdoc__drop { - opacity: 0; -} - -.fdoc__money:hover .fdoc__drop, -.fdoc__drop:focus-visible { - opacity: 1; -} - .fdoc__grouphead { - grid-column: 1 / -1; padding-top: var(--spacing-8); } @@ -210,24 +241,28 @@ border-top: 1px solid var(--color-border); } -.fdoc__total.is-end { - border-top: 2px solid var(--color-border-strong); -} - -.fdoc__add { - color: var(--color-text-secondary); -} - -/* 기호 정의 — 값 식 아래 한 줄씩(오른쪽 칸 자리) · 펼친 단가산출 블록만 격자 전체 폭 */ +/* 기호 정의 — 오른쪽 영역 한 줄씩(머리 아래 한 상자) */ .fdoc__defs { - grid-column: 1 / -1; display: flex; flex-direction: column; gap: 2px; - padding-top: var(--spacing-12); 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); } @@ -255,13 +290,6 @@ font-size: var(--text-body-sm); } -.fdoc__defs > .fdoc__colhead, -.fdoc__def, -.fdoc__defedit, -.fdoc__unused > summary { - margin-left: calc(50% + var(--spacing-24) / 2); -} - .fdoc__defedit { display: flex; flex-direction: column; @@ -298,17 +326,10 @@ border-left: 2px solid var(--color-accent); } -@container (max-width: 720px) { - .fdoc__grid { +@container (max-width: 900px) { + .fdoc__areas { grid-template-columns: minmax(0, 1fr); } - - .fdoc__defs > .fdoc__colhead, - .fdoc__def, - .fdoc__defedit, - .fdoc__unused > summary { - margin-left: 0; - } } .fdoc__wrap, @@ -319,11 +340,71 @@ gap: 0 var(--spacing-4); } -/* 수량 없음 = 1 — 고치는 자리만(줄에 마우스를 올릴 때 흐리게) */ -.fdoc__money .is-ghost { - opacity: 0; +/* 수식 영역 편집기(803-2a · `formula_doc_math.ts`) — 치는 대로 조판 · 커서 · 숨은 글 칸 */ +.fdoc-math { + position: relative; + cursor: text; } -.fdoc__money:hover .is-ghost { - opacity: 0.45; +.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_edit.ts b/A00_Common/formula_doc_edit.ts index 75efa6f44..3abbb5806 100644 --- a/A00_Common/formula_doc_edit.ts +++ b/A00_Common/formula_doc_edit.ts @@ -2,18 +2,20 @@ * 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 } from "./formula_doc_render"; +import { swapLit, tryParse } from "./formula_doc_render"; /** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */ export function splitLine(text: string): [string | null, string] { @@ -90,3 +92,63 @@ export function editLit( 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_foot.ts b/A00_Common/formula_doc_foot.ts new file mode 100644 index 000000000..3a224b188 --- /dev/null +++ b/A00_Common/formula_doc_foot.ts @@ -0,0 +1,20 @@ +/* ============================================================================= + * formula_doc_foot.ts + * 수식 탭 맨 아래 합계 상자(PLAN 803-2a · 요구 667-2) — 단가산출 상세 탭과 같은 부품(`buildFoot` · 노무비 · 재료비 · 경비 · 계) + * 값 = 계산기 셈 그대로(여기서 다시 셈 안 함) · 셈 못 함 = 「—」 + * ========================================================================== */ + +import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { buildFoot } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Foot"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; + +const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + +export function docFoot(a: DocAnswer): HTMLElement { + const done = !!a.셈됨 && typeof a.계 === "number"; + return buildFoot( + { labor: num(a.노무비), material: num(a.재료비), cost: num(a.경비) }, + done, + num(a.계), + ); +} diff --git a/A00_Common/formula_doc_lines.ts b/A00_Common/formula_doc_lines.ts index 51ee5603f..031dcd9ca 100644 --- a/A00_Common/formula_doc_lines.ts +++ b/A00_Common/formula_doc_lines.ts @@ -1,18 +1,17 @@ /* ============================================================================= * formula_doc_lines.ts - * 수식 문서 식 줄 · 금액 줄 · 묶음(PLAN 802-3 · 요구 660~664) — 왼쪽 기호 식 · 오른쪽 값 식 한 줄씩(격자 두 칸) - * 식 줄: 왼쪽 `Q = 3600 × q … ÷ Cm`(그 자리 고침 = 공용 `editEq` · 머리 = 함께) · 오른쪽 `= 값 식 = 결과 단위` - * (값 식 숫자 누름 = 그 자리 값 고침 · 기호 값 누름 = 정의 줄 열림 · 그 밖 누름 = 식 고침) - * 금액 줄: 경쟁사 꼴 `이름 : 단가 × 수량 = 금액`(÷ 한 줄) · 묶음 줄 = 노무비 · 재료비 · 경비 세 줄 - * 묶음: 돈 묶음 하나 = 머리 없이 「계」 · 둘 이상 = 「가. 이름」마다 「소계」 + 맨 끝 「합계」(지어낸 말 없음) + * 수식 문서 줄(PLAN 802-3 · 803-2a · 요구 660~667) — 왼쪽 「기호 식」 = 줄바꿈으로 이어진 하나의 수식 영역 · 가운데 「값 식」 같은 높이 + * 식 줄: `Q = 3600 × q … ÷ Cm` · 값 식 `= 값 식 = 결과 단위` + * 금액 줄: `노무비 형틀목공 = w × Q`(비목 · 이름 = 단가 × 수량) · 값 식 `= 값 × 값 = 금액`(묶음 = 노 · 재 · 경 세 줄) + * 묶음: 하나 = 머리 없음 · 둘 이상 = 「가. 이름」 머리 + 「소계」 · 맨 끝 합은 화면 맨 아래 합계 상자 + * 왼쪽 줄 누름 = 그 자리 편집기(`formula_doc_math.ts` · 부른 쪽 `mount`) · 영역 맨 아래 빈 줄 = 새 줄 + * 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 그 밖 = 그 줄 편집 * 고친 것은 부른 쪽(`formula_doc_screen.ts`) 훅으로 — 여기선 문서를 안 고침 * ========================================================================== */ -import { createButton, createIconButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; -import { editEq, editNumber, serializeEq, tidyEq } from "@ui/ui_template_equation_edit"; +import { editNumber, serializeEq, tidyEq } from "@ui/ui_template_equation_edit"; import { el } from "@ui/ui_template_elements"; -import { createSelectField } from "@ui/ui_template_input"; 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"; @@ -30,29 +29,31 @@ import { import { ft } from "./formula_doc_text"; export const COSTS = ["노무비", "재료비", "경비"] as const; -const KIND = [...COSTS, "묶음"]; const ORDER = "가나다라마바사아자차카타파하"; -export type EditAt = { kind: "line" | "expr" | "price" | "qty" | "name"; i: number } | null; +/** 줄 자리 — 식 줄 · 금액 줄 차례 */ +export interface RowRef { + kind: "line" | "money"; + i: number; +} export interface LinesHooks { admin: boolean; /** 선택으로 꺼진 금액 줄도 보임(고침 모드) */ showOff?: boolean; - onShowOff?: () => void; doc: FormulaDoc; answer: DocAnswer; vars: Map; /** 기호 · 금액 줄 이름 → 표지 글 */ marks: Map; - auto: EditAt; - onLine: (i: number, text: string) => void; - onExpr: (i: number, text: string) => void; - onMoney: (i: number, patch: Partial | null) => void; + /** 편집 중인 줄 */ + 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; - onAddLine: () => void; - onAddMoney: (group: string) => void; onGroupName: (old: string, now: string) => void; onRedraw: () => void; } @@ -104,40 +105,39 @@ function expr( }; } -/** 그 자리 고침 시작 — 누른 자리에 커서 */ -function startEdit( - host: HTMLElement, - h: LinesHooks, - commit: (text: string) => void, - at?: { x: number; y: number }, -): void { - if (host.dataset.editing) return; - editEq(host, { - at, - onInput: (text) => { - const body = text.includes(" = ") ? text.slice(text.indexOf(" = ") + 3) : text; - host.toggleAttribute("data-bad", !!body.trim() && !tryParse(body)); - }, - onCommit: commit, - onCancel: () => h.onRedraw(), - }); - // 전체 고르기 = 한 줄 글(선형 꼴)로 바꿔 통째로 고침 · 지움 - host.addEventListener("keydown", (ev) => { - if (!(ev.ctrlKey || ev.metaKey) || ev.key.toLowerCase() !== "a") return; - ev.preventDefault(); - host.textContent = tidyEq(serializeEq(host)); - window.getSelection()?.selectAllChildren(host); +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, - editAll: () => void, + ref: RowRef, ): void { if (!h.admin || host.dataset.editing) return; const t = ev.target as HTMLElement; @@ -153,13 +153,13 @@ function onValueClick( } else if (atom && host.contains(atom)) { ev.stopPropagation(); h.onPickVar(atom.dataset.var!, atom.querySelector(".ui-eq__num") ?? undefined); - } else editAll(); + } 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 left = el("div", { className: "fdoc__cell fdoc__sym", attrs: { "data-line": String(i) } }); const edit = span("fdoc__edit"); if (head) { const a = span("ui-eq__var fdoc__atom fdoc__head"); @@ -173,7 +173,7 @@ function lineRow(i: number, h: LinesHooks): HTMLElement[] { edit.dataset.bad = "1"; edit.title = bad; } - left.append(edit); + const left = areaCell(h, ref, edit, () => tidyEq(serializeEq(edit))); const right = el("div", { className: "fdoc__cell fdoc__valcell", attrs: { "data-line": String(i) }, @@ -192,18 +192,7 @@ function lineRow(i: number, h: LinesHooks): HTMLElement[] { } 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) { - const commitLine = (text: string): void => h.onLine(i, text); - left.addEventListener("click", (ev) => - startEdit(edit, h, commitLine, { x: ev.clientX, y: ev.clientY }), - ); - vexpr.addEventListener("click", (ev) => - onValueClick(ev, vexpr, h, "line", i, () => - startEdit(vexpr, h, (text) => h.onExpr(i, text), { x: ev.clientX, y: ev.clientY }), - ), - ); - if (h.auto?.kind === "line" && h.auto.i === i) startEdit(edit, h, commitLine); - } + if (h.admin) vexpr.addEventListener("click", (ev) => onValueClick(ev, vexpr, h, "line", i, ref)); return [left, right]; } @@ -212,66 +201,24 @@ const unitOf = (id: string, h: LinesHooks): string => 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 left = el("div", { className: "fdoc__cell fdoc__sym fdoc__money" }); - const name = span("fdoc__name", line.이름 || ft("Unnamed")); - const price = span("fdoc__edit"); - price.append(expr(line.단가, h, "sym", true).node); - const qty = span("fdoc__edit"); - const hasQty = line.수량 !== null && line.수량 !== undefined && line.수량 !== ""; - if (hasQty) qty.append(expr(line.수량, h, "sym", true).node); - else qty.append(span("ui-eq__num", "1")); - const times = span("ui-eq__op", "×"); - left.append(name, span("ui-eq__op", ":"), price); - // 수량 없음 = 1(흐리게 — 눌러 수량을 씀 · 묶음 줄은 단가 × 수량이어야 셈) - if (hasQty || h.admin) left.append(times, qty); - if (!hasQty) [times, qty].forEach((x) => x.classList.add("is-ghost")); - const bad = h.marks.get(line.이름 ?? ""); + 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) { - left.dataset.bad = "1"; - left.title = bad; - } - if (h.admin) { - const kind = createSelectField({ - options: KIND.map((k) => ({ value: k, text: k })), - value: line.비목 ?? "노무비", - compact: true, - size: 22, - ariaLabel: ft("Kind"), - onChange: (v) => h.onMoney(i, { 비목: v }), - }); - kind.root.classList.add("fdoc__kind"); - const drop = createIconButton({ - glyph: "✕", - title: ft("Drop_Line"), - size: 20, - variant: "ghost", - onClick: (ev) => { - ev.stopPropagation(); - h.onMoney(i, null); - }, - }); - drop.classList.add("fdoc__drop"); - left.prepend(kind.root); - left.append(drop); - name.addEventListener("click", () => - editName(name, line.이름 ?? "", (t) => h.onMoney(i, { 이름: t })), - ); - const put = (slot: "단가" | "수량") => (text: string) => h.onMoney(i, { [slot]: text }); - price.addEventListener("click", (ev) => - startEdit(price, h, put("단가"), { x: ev.clientX, y: ev.clientY }), - ); - qty.addEventListener("click", (ev) => - startEdit(qty, h, put("수량"), { x: ev.clientX, y: ev.clientY }), - ); - if (h.auto?.kind === "price" && h.auto.i === i) startEdit(price, h, put("단가")); - if (h.auto?.kind === "name" && h.auto.i === i) - editName(name, "", (t) => h.onMoney(i, { 이름: t })); + 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 = line.비목 === "묶음" ? [...COSTS] : [null]; + 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", ":")); @@ -282,11 +229,9 @@ function moneyRow(i: number, h: LinesHooks): HTMLElement[] { 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, () => undefined)); + 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, () => undefined)); + 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))); @@ -328,12 +273,13 @@ function editName(node: HTMLElement, before: string, done: (text: string) => voi }); } -function totalRow(label: string, b: Record | null, cls: string): HTMLElement[] { +/** 소계 · 계 줄(왼쪽 이름 · 가운데 노 · 재 · 경 · 계) */ +export function sumRow(label: string, b: Record | null): HTMLElement[] { const left = el("div", { - className: `fdoc__cell fdoc__total ${cls}`, + className: "fdoc__cell fdoc__sym fdoc__total", children: [span("fdoc__name", label)], }); - const right = el("div", { className: `fdoc__cell fdoc__total ${cls}` }); + const right = el("div", { className: "fdoc__cell fdoc__total" }); for (const k of [...COSTS, "계"]) { const v = b?.[k]; right.append( @@ -344,26 +290,26 @@ function totalRow(label: string, b: Record | null, cls: string) return [left, right]; } -/** 식 줄 · 금액 줄 · 묶음 · 계 — 격자 칸 차례 */ -export function buildLines(h: LinesHooks): HTMLElement[] { +/** 선택으로 꺼진 금액 줄 차례 */ +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); - const pair = (left: HTMLElement | null, right: HTMLElement | null): void => - add([left ?? el("div"), right ?? el("div")]); - (h.doc.식 ?? []).forEach((_, i) => add(lineRow(i, h))); - if (h.admin) - pair( - el("div", { - className: "fdoc__cell", - children: [button(ft("Add_Line"), h.onAddLine)], - }), - null, - ); + (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 steps = lineMap(h.doc); - const off = new Set(lines.flatMap((l, i) => (offLine(l, vals(h), steps) ? [i] : []))); + 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[]; @@ -376,42 +322,35 @@ export function buildLines(h: LinesHooks): HTMLElement[] { head.append(label); if (h.admin) label.addEventListener("click", () => editName(label, g, (t) => h.onGroupName(g, t))); - add([el("div", { className: "fdoc__cell fdoc__grouphead", children: [head] })]); // 두 칸 걸침 + add([ + el("div", { className: "fdoc__cell fdoc__sym fdoc__grouphead", children: [head] }), + el("div"), + ]); } - lines.forEach((l, i) => (l.묶음 ?? "") === g && seen(i) && add(moneyRow(i, h))); - if (h.admin && lines.length) - pair( - el("div", { - className: "fdoc__cell", - children: [button(ft("Add_Money"), () => h.onAddMoney(g))], - }), - null, - ); - if (many) add(totalRow(ft("Subtotal"), done ? (bundles[gi] ?? null) : null, "is-sub")); + 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 && !lines.length) - pair( - el("div", { - className: "fdoc__cell", - children: [button(ft("Add_Money"), () => h.onAddMoney(""))], - }), - null, - ); - if (h.admin && lines.length) { - const kids = [button(ft("Add_Group"), () => h.onAddMoney(ft("New_Group")))]; - if (off.size && h.onShowOff) - kids.push(button(`${h.showOff ? ft("Hide_Off") : ft("Show_Off")} ${off.size}`, h.onShowOff)); - pair(el("div", { className: "fdoc__cell", children: kids }), 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")]); } - if (lines.length) { - const sum = done ? (h.answer as unknown as Record) : null; - add(totalRow(many ? ft("Total") : ft("Sum"), sum, "is-end")); + // 칸 테 — 기호 식 · 값 식 영역마다 맨 위 · 맨 아래 + 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 out; + return { cells: out, order }; } - -const button = (label: string, onClick: () => void): HTMLElement => { - const b = createButton({ label, variant: "ghost", size: 24, onClick }); - b.classList.add("fdoc__add"); - return b; -}; diff --git a/A00_Common/formula_doc_math.ts b/A00_Common/formula_doc_math.ts new file mode 100644 index 000000000..3c24927d4 --- /dev/null +++ b/A00_Common/formula_doc_math.ts @@ -0,0 +1,380 @@ +/* ============================================================================= + * 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 new file mode 100644 index 000000000..ba6aec420 --- /dev/null +++ b/A00_Common/formula_doc_math_model.ts @@ -0,0 +1,259 @@ +/* ============================================================================= + * 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 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_rows.ts b/A00_Common/formula_doc_rows.ts new file mode 100644 index 000000000..3fcbcd3a0 --- /dev/null +++ b/A00_Common/formula_doc_rows.ts @@ -0,0 +1,199 @@ +/* ============================================================================= + * 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 index 23c98f891..35f67cb4d 100644 --- a/A00_Common/formula_doc_screen.ts +++ b/A00_Common/formula_doc_screen.ts @@ -1,9 +1,11 @@ /* ============================================================================= * formula_doc_screen.ts - * 단가산출 기초 수식 문서 화면(PLAN 802-3 · 요구 660~665 · 요구서_664 3 장) — 공용(M01 · 뒤에 M02 = 길 앞머리만 바꿈) - * 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · 결과/계 · [수식 저장] · [고침 버림](관리자) - * 가운데 = 격자 두 칸 — 왼쪽 기호 식 · 오른쪽 값 식(`formula_doc_lines.ts`) · 오른쪽 아래 기호 정의(`formula_doc_defs.ts`) - * 빈 문서에서 시작 — [+ 식 줄] 에 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름 + * 단가산출 기초 수식 문서 화면(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`(서버가 정본으로 다시 셈 · 파일에 씀) @@ -16,7 +18,6 @@ import { API_BASE_URL } from "@config/config_frontend"; 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, @@ -28,11 +29,11 @@ import { import { createInputField } from "@ui/ui_template_input"; import { DocError, + blankDoc, rename, varId, type FormulaDoc, type Link, - type MoneyLine, } 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"; @@ -47,10 +48,13 @@ import { type JobReply, } from "./formula_doc_copy"; import { buildDefs, type Pick } from "./formula_doc_defs"; -import { editLine, editLit, editMoney, ensureVar, splitLine, tidyText } from "./formula_doc_edit"; -import { buildLines, type EditAt } from "./formula_doc_lines"; +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"; @@ -84,28 +88,36 @@ 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 marks = el("div", { className: "fdoc__marks" }); 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, marks, grid], + children: [head, el("div", { className: "fdoc__areas", children: [grid, side] }), foot], }); let key = ""; let saved = ""; - let fresh = false; let doc: FormulaDoc | null = null; let answer: DocAnswer | null = null; let cache = createDocCache(); let given: Record = {}; let openDef: string | null = null; - let auto: EditAt = null; let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드) let seq = 0; let asked = ""; let ctl: AbortController | null = null; - let waiting = false; 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 = "{}"; @@ -181,7 +193,6 @@ export function createDocScreen(opt: DocScreenOptions) { ctl?.abort(); ctl = new AbortController(); const mine = ++seq; - waiting = true; void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>( "/preview", cp ? { 문서: doc, 끊음: cut } : { 문서: doc, 입력: usable() }, @@ -192,13 +203,10 @@ export function createDocScreen(opt: DocScreenOptions) { cache.merge(got.연결); calls = { ...calls, ...(got.부름 ?? {}) }; cache.merge({ 문서: cut }); - waiting = false; recalc(); draw(); }) - .catch(() => { - if (mine === seq) waiting = false; - }); + .catch(() => undefined); }; const change = (fn: (d: FormulaDoc) => void): void => { @@ -209,13 +217,6 @@ export function createDocScreen(opt: DocScreenOptions) { draw(); }; - // ── 고침(`formula_doc_edit.ts`) ── - const onMoney = (i: number, patch: Partial | null): void => - change((d) => { - editMoney(d, i, patch); - if (patch && "이름" in patch && !d.금액?.[i]?.단가) auto = { kind: "price", i }; - }); - const onPickVar = (id: string, num?: HTMLElement): void => { const v = doc?.변수?.find((x) => varId(x) === id); if (opt.admin && num && v?.연결?.갈래 === "숫자") { @@ -309,31 +310,10 @@ export function createDocScreen(opt: DocScreenOptions) { draw(); }); - const headLine = (): HTMLElement => { - const a = answer; - const bits: (HTMLElement | string)[] = []; - if (doc?.결과) { - const r = a?.결과; - bits.push( - el("strong", { text: ft("Result") }), - el("span", { - className: "fdoc__headval", - attrs: { "data-total": "" }, - text: typeof r === "number" ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—", - }), - ); - } else { - const total = a?.계; - bits.push( - el("strong", { text: a?.머리 === "합계" ? ft("Total") : ft("Sum") }), - el("span", { - className: "fdoc__headval", - attrs: { "data-total": "" }, - text: typeof total === "number" ? `${eqText(total)} ${doc?.결과단위 ?? ""}` : "—", - }), - ); - } - if (opt.admin && doc && !(doc.금액 ?? []).length) { + /** 위 도구 줄 — 결과 기호(금액 줄 없을 때) · 고침 표 · 사본 표 · [꺼진 줄] · [고침 버림] · [수식 저장] */ + const toolRow = (edit: boolean): HTMLElement => { + const bits: HTMLElement[] = []; + if (edit && doc && !(doc.금액 ?? []).length) { const f = createInputField({ placeholder: ft("Result_Sym"), value: doc.결과 ?? "", @@ -350,29 +330,49 @@ export function createDocScreen(opt: DocScreenOptions) { f.root.classList.add("fdoc__resultsym"); bits.push(f.root); } - if (waiting) - bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Loading") })); - if (fresh && !dirty()) - bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Fresh") })); if (dirty()) bits.push(createBadge(ft("Dirty"), "warning")); if (cp) bits.push(...copyBadges(mark, opt.admin)); - const tools = 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)) : []), - createButton({ - label: ft("Discard"), - variant: "ghost", - size: 28, - disabled: !dirty(), - onClick: () => void discard(), - }), - createButton({ - label: ft("Save"), - variant: "filled", - size: 28, - disabled: !dirty(), - onClick: () => void save(), - }), + 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", { @@ -384,29 +384,13 @@ export function createDocScreen(opt: DocScreenOptions) { }); }; - const markBox = (): void => { - const list = answer?.표지 ?? []; - marks.replaceChildren(); - if (!list.length) return; - const count = new Map(); - for (const m of list) count.set(m.갈래, (count.get(m.갈래) ?? 0) + 1); - marks.append( - ...[...count].map(([k, n]) => - createBadge(`${k} ${n}`, k === "빈 문서" ? "neutral" : "warning"), - ), - ...list - .slice(0, 4) - .map((m) => el("span", { className: "ui-text ui-text--muted fdoc__marktext", text: m.글 })), - ); - }; - const draw = (): void => { + rows.beforeDraw(); if (!doc || !answer) { - head.replaceChildren(); - marks.replaceChildren(); - grid.replaceChildren(); + [head, grid, side, foot].forEach((x) => x.replaceChildren()); return; } + const edit = opt.admin && !!key; const vals = answer.값 ?? {}; const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); const lineSet = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)); @@ -419,39 +403,20 @@ export function createDocScreen(opt: DocScreenOptions) { const options = optionBar(); head.replaceChildren( ...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []), - headLine(), - ...(opt.admin - ? [el("p", { className: "ui-text ui-text--muted fdoc__hint", text: ft("Hint") })] - : []), + toolRow(edit), ); - markBox(); - const lines = buildLines({ - admin: opt.admin, + const { cells, order } = buildLines({ + admin: edit, showOff, - onShowOff: () => { - showOff = !showOff; - draw(); - }, doc, answer, vars, marks: markMap, - auto, - onLine: (i, t) => change((d) => editLine(d, i, t)), - onExpr: (i, t) => change((d) => (d.식![i].식 = tidyText(t))), - onMoney, + editing: rows.editing(), + mount: rows.mount, + onEditAt: rows.editAt, onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)), onPickVar, - onAddLine: () => { - auto = { kind: "line", i: (doc!.식 ?? []).length }; - change((d) => (d.식 ??= []).push({ 기호: "", 식: "" })); - }, - onAddMoney: (g) => { - auto = { kind: "name", i: (doc!.금액 ?? []).length }; - change((d) => - (d.금액 ??= []).push({ 이름: "", 비목: "노무비", 단가: "", ...(g ? { 묶음: g } : {}) }), - ); - }, onGroupName: (old, now) => change((d) => (d.금액 ?? []).forEach((l) => { @@ -462,9 +427,9 @@ export function createDocScreen(opt: DocScreenOptions) { ), onRedraw: () => redraw(), }); - auto = null; + rows.setOrder(order); const defs = buildDefs({ - admin: opt.admin, + admin: edit, vars, names: cache.names, lines: lineSet, @@ -505,8 +470,9 @@ export function createDocScreen(opt: DocScreenOptions) { }, makeLine: (id) => { if (lineSet.has(id)) return; - auto = { kind: "line", i: (doc!.식 ?? []).length }; + 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 }), @@ -517,20 +483,27 @@ export function createDocScreen(opt: DocScreenOptions) { .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( - el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }), + symHead, el("div", { className: "fdoc__colhead", text: ft("Val_Head") }), - ...lines, - defs, + ...cells, ); + side.replaceChildren(defs); + foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)])); }; /** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */ const redraw = (): void => { if (!doc) return; const before = JSON.stringify(doc); - doc.식 = (doc.식 ?? []).filter((l) => String(l.기호 ?? "") || String(l.식 ?? "")); - doc.금액 = (doc.금액 ?? []).filter((l) => l.이름 || l.단가 || l.수량); + sweep(doc); if (JSON.stringify(doc) !== before) { keep(); recalc(); @@ -573,8 +546,11 @@ export function createDocScreen(opt: DocScreenOptions) { if (await askJob(job, got, names)) await save({ 할일: job, 판: version }, ft("Job_Done")); }; - const save = async (send = body(), done = ft("Saved")): Promise => { + 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 }) => { @@ -589,7 +565,6 @@ export function createDocScreen(opt: DocScreenOptions) { return call("/save", { ...send, 판: (version = now.판 ?? "") }); }); take(got); - fresh = false; keep(); recalc(); draw(); @@ -609,7 +584,8 @@ export function createDocScreen(opt: DocScreenOptions) { }; const discard = async (): Promise => { - if (!doc || !dirty()) return; + rows.reset(); + if (!doc || !dirty()) return void draw(); back(); openDef = null; keep(); @@ -620,6 +596,7 @@ export function createDocScreen(opt: DocScreenOptions) { /** 문서 열기 — 같은 키면 그대로(다시 그림만) */ const open = async (next: string): Promise => { if (next === key && doc) return; + rows.reset(); key = next; doc = answer = null; given = {}; @@ -631,16 +608,17 @@ export function createDocScreen(opt: DocScreenOptions) { [cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""]; draw(); if (!next) { - const why = cp ? ft("No_Key") : ft("Need_Save"); - head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: why })); - return; + // 저장 전 새 로직 — 세 영역 빈 화면(저장하면 고칠 수 있음) + doc = blankDoc(); + saved = JSON.stringify(doc); + recalc(); + return draw(); } const mine = ++seq; try { const got = await call(cp ? "" : `?key=${encodeURIComponent(next)}`); if (mine !== seq || key !== next) return; take(got); - fresh = !!got.새; const draft = drafts()[next]; if (draft) doc = clone(draft); const kept = cp ? (drafts()[`${next}#끊음`] as unknown as Docs | undefined) : undefined; @@ -659,6 +637,7 @@ export function createDocScreen(opt: DocScreenOptions) { }; const canLeave = async (): Promise => { + rows.flush(); if (!dirty()) return true; if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false; back(); diff --git a/A00_Common/formula_doc_sub.ts b/A00_Common/formula_doc_sub.ts index a2e0adfd3..16aa9f5b9 100644 --- a/A00_Common/formula_doc_sub.ts +++ b/A00_Common/formula_doc_sub.ts @@ -3,6 +3,7 @@ * 단가산출 연결 펼침(PLAN 802-3 · 802-4b-2 · 요구 659-3 · 796-2e 뜻) — 정의 영역에서 단가산출 기호를 열면 그 문서의 식 · 값 식 * M01 = 읽기만 · M02 사본(`copy`) = 머리 「연결」 + [연결 끊기](이 사본 안 자기 문서로 굳힘 · 금액 그대로) · * 「끊음」 + [다시 잇기](고친 것 버림 · 부름 문서로) · 끊은 문서 = 그 자리에서 식 줄 · 금액 줄 · 숫자 고침(`formula_doc_edit.ts`) + * · 줄 편집 = 수식 영역과 같은 편집기(`formula_doc_math.ts` · 803-2a) · 맨 아래 계 줄 * 셈 = 같은 계산기 · 연결 캐시(끊은 문서는 캐시 문서를 덮어씀 — 부른 쪽이 `merge`) * ========================================================================== */ @@ -14,8 +15,9 @@ 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 { editLine, editLit, editMoney, ensureVar, tidyText } from "./formula_doc_edit"; -import { buildLines, type EditAt } from "./formula_doc_lines"; +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"; @@ -39,7 +41,8 @@ export interface SubHooks { copy?: SubCopy; } -let next: { key: string; at: EditAt } | null = null; // 끊은 문서에 새 줄 — 다음 그림에서 바로 고침 +/** 끊은 문서에서 편집 중인 줄 — 다음 그림에서 편집기를 엶 */ +let next: { key: string; ref: RowRef; at?: { x: number; y: number }; seed?: string } | null = null; const subDoc = (cache: DocCache, k: string): FormulaDoc | null => { try { @@ -90,9 +93,43 @@ export function subBlock(id: string, h: SubHooks): HTMLElement | null { const live = !!(c?.edit && c.cut(k)); const put = (fn: (d: FormulaDoc) => void): void => c!.change(k, fn); const none = (): void => undefined; - const auto = next?.key === k ? next.at : null; + 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, @@ -100,10 +137,9 @@ export function subBlock(id: string, h: SubHooks): HTMLElement | null { answer: got, vars: new Map((sub.변수 ?? []).map((v) => [varId(v), v])), marks: new Map(), - auto, - onLine: live ? (i, t) => put((d) => editLine(d, i, t)) : none, - onExpr: live ? (i, t) => put((d) => (d.식![i].식 = tidyText(t))) : none, - onMoney: live ? (i, p) => put((d) => editMoney(d, i, p)) : none, + 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); @@ -113,24 +149,12 @@ export function subBlock(id: string, h: SubHooks): HTMLElement | null { put((d) => (ensureVar(d, vid).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t })); }); }, - onAddLine: () => { - next = { key: k, at: { kind: "line", i: (sub.식 ?? []).length } }; - put((d) => (d.식 ??= []).push({ 기호: "", 식: "" })); - }, - onAddMoney: (g) => { - next = { key: k, at: { kind: "name", i: (sub.금액 ?? []).length } }; - put((d) => - (d.금액 ??= []).push({ 이름: "", 비목: "노무비", 단가: "", ...(g ? { 묶음: g } : {}) }), - ); - }, onGroupName: none, - onRedraw: () => - put((d) => { - d.식 = (d.식 ?? []).filter((l) => String(l.기호 ?? "") || String(l.식 ?? "")); - d.금액 = (d.금액 ?? []).filter((l) => l.이름 || l.단가 || l.수량); - }), - }), + 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( diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index 171f1d378..9e4006b27 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -1,6 +1,6 @@ /* ============================================================================= * formula_doc_text.ts - * 수식 문서 화면 글(PLAN 802-3) — [국문, 영문] + * 수식 문서 화면 글(PLAN 802-3 · 803-2a) — [국문, 영문] * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -9,20 +9,15 @@ const TEXT = { Sym_Head: ["기호 식", "Symbol equation"], Val_Head: ["값 식", "Value equation"], Defs_Head: ["기호 정의", "Symbols"], - Add_Line: ["+ 식 줄", "+ Line"], - Add_Money: ["+ 금액 줄", "+ Cost line"], - Add_Group: ["+ 묶음", "+ Group"], New_Group: ["새 묶음", "New group"], Unnamed: ["이름 없음", "Unnamed"], - Kind: ["비목", "Cost type"], - Drop_Line: ["줄 지움", "Delete line"], Subtotal: ["소계", "Subtotal"], Sum: ["계", "Sum"], Total: ["합계", "Total"], Need: ["정의 필요", "Needs definition"], Unused: ["쓰지 않는 기호", "Unused symbols"], - Show_Off: ["선택으로 꺼진 줄 보기", "Show switched-off lines"], - Hide_Off: ["선택으로 꺼진 줄 숨김", "Hide switched-off lines"], + Show_Off: ["꺼진 줄 보기", "Show off lines"], + Hide_Off: ["꺼진 줄 숨김", "Hide off lines"], Save: ["수식 저장", "Save equation"], Discard: ["고침 버림", "Discard"], Saved: ["수식 저장됨", "Equation saved"], @@ -30,19 +25,9 @@ const TEXT = { Load_Failed: ["수식 문서를 못 읽음", "Failed to load"], Leave: ["저장 안 한 수식 고침이 있음 — 버리고 나갈까요?", "Discard unsaved edits?"], Leave_Ok: ["버리고 나감", "Discard"], - Need_Save: [ - "새 로직 — [저장] 하면 빈 수식 문서에서 식을 씀", - "New logic — save it first, then write the equation", - ], - Fresh: ["수식 문서 없음 — 빈 문서에서 시작", "No equation yet — start from blank"], Result: ["결과", "Result"], Result_Sym: ["결과 기호", "Result symbol"], Dirty: ["저장 안 한 고침", "Unsaved edits"], - Loading: ["불러오는 중", "Loading"], - Hint: [ - "식을 눌러 그 자리에서 고침 · 키보드 + − × ÷ / ( ) % · 첨자 Q_d · Q^d · Enter 넣음 · Esc 버림", - "Click to edit in place · Enter to apply · Esc to cancel", - ], Sign: ["기호", "Symbol"], Low: ["아래첨자", "Subscript"], Up: ["위첨자", "Superscript"], @@ -96,6 +81,14 @@ const TEXT = { "Saved items only — use the detail tab", ], Read_Only: ["읽기만", "Read only"], + Tools: ["수식 도구", "Equation tools"], + Tool_Frac: ["분수", "Fraction"], + Tool_Sub: ["아래첨자", "Subscript"], + Tool_Sup: ["위첨자", "Superscript"], + Tool_Paren: ["( )", "( )"], + Tool_If: ["만약", "If"], + Tool_Round: ["반올림", "Round"], + Tool_Group: ["묶음", "Group"], } as const; export type TextKey = keyof typeof TEXT;