feat(803-2a): 수식 탭 세 영역 · 하나의 수식 영역 · 합계 상자 · 설명 글 지움

- 세 영역 가로 — 기호 식(줄바꿈으로 이어진 하나의 수식 영역) | 값 식(같은 높이 줄) | 기호 정의
- 맨 아래 합계 상자 = 단가산출 상세 탭 부품(노무비 · 재료비 · 경비 · 계) · 옵션 아래 계 줄 · 설명 글 · 표지 안내 줄 걷음(오류 = 그 기호 빨강)
- 기호 식 줄 누름 = 그 자리 편집기(치는 대로 조판 — / 분수 · _ 아래첨자 · ^ 위첨자 · 괄호 · 화살표 구조 이동 · Enter 새 줄 · Esc 나옴 · 한글 조합) · 편집 중만 영역 위 수식 도구 줄
- 금액 줄 = 영역 안 식 줄 「노무비 형틀목공 = w × Q」(머리 첫 말 = 비목) · 「+ 식 줄」 「+ 금액 줄」 「+ 묶음」 · 비목 고르기 칸 걷음 · 묶음 = 도구 줄 [묶음]
- 저장 전 새 로직 = 세 영역 빈 화면 · 끊은 단가산출 블록도 같은 편집기
- 새 라이브러리 없음 · 서버 길 · 계산기 · 문서 꼴 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 21:07:59 +09:00
co-authored by Claude Opus 5.5
parent 2057e0ec02
commit f5545dd62b
10 changed files with 1341 additions and 405 deletions
+148 -67
View File
@@ -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;
}
+63 -1
View File
@@ -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.수량);
}
+20
View File
@@ -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.계),
);
}
+106 -167
View File
@@ -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<string, DocVar>;
/** 기호 · 금액 줄 이름 → 표지 글 */
marks: Map<string, string>;
auto: EditAt;
onLine: (i: number, text: string) => void;
onExpr: (i: number, text: string) => void;
onMoney: (i: number, patch: Partial<MoneyLine> | 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<HTMLElement>(".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<string, number>; 금액?: 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<string, unknown> | null, cls: string): HTMLElement[] {
/** 소계 · 계 줄(왼쪽 이름 · 가운데 노 · 재 · 경 · 계) */
export function sumRow(label: string, b: Record<string, unknown> | 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<string, unknown> | null, cls: string)
return [left, right];
}
/** 식 줄 · 금액 줄 · 묶음 · 계 — 격자 칸 차례 */
export function buildLines(h: LinesHooks): HTMLElement[] {
/** 선택으로 꺼진 금액 줄 차례 */
export function offLines(doc: FormulaDoc, answer: DocAnswer): Set<number> {
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<string, unknown>[];
@@ -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<string, unknown>) : 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;
};
+380
View File
@@ -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<Item[], HTMLElement>();
const lists = new WeakMap<Element, Item[]>();
const chars = new WeakMap<Element, Caret>();
// ── 그림 ──
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;
}
+259
View File
@@ -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<string, string> = { "<": "≤", ">": "≥", "!": "≠", "=": "==" };
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);
}
+199
View File
@@ -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");
});
},
};
}
+107 -128
View File
@@ -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 = <T>(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<string, unknown> = {};
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<string, Promise<DocTable | null>>();
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<MoneyLine> | 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<string, number>();
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<void> => {
const save = async (send?: Record<string, unknown>, done = ft("Saved")): Promise<void> => {
if (!doc) return;
rows.flush();
if (!send && !dirty()) return void draw();
send ??= body();
showLoadingOverlay();
try {
const got = await call<Loaded>("/save", send).catch(async (e: { status?: number }) => {
@@ -589,7 +565,6 @@ export function createDocScreen(opt: DocScreenOptions) {
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
});
take(got);
fresh = false;
keep();
recalc();
draw();
@@ -609,7 +584,8 @@ export function createDocScreen(opt: DocScreenOptions) {
};
const discard = async (): Promise<void> => {
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<void> => {
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<Loaded>(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<boolean> => {
rows.flush();
if (!dirty()) return true;
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
back();
+48 -24
View File
@@ -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<string, unknown>) : null));
if (sub.결과) {
const r = got.결과;
box.append(
+11 -18
View File
@@ -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;