feat(804-6): 옛 수식 탭 걷음 · 줄 쌓기 = 첫 「수식」 탭(요구 686-1)
- M01 단가산출 기초 · M02 단가산출 상세 보기 = 수식(줄 쌓기 · 기본) · 단가산출 상세 둘 - 줄 쌓기에 M02 사본 [단가 갱신] · [재생성](미리 셈 → 알림 → 저장 · 고침 있으면 막음) · 판 바뀜(409) 문서 그대로면 새 판으로 다시 저장 옮김 - 옛 수식 문서 화면 걷음(formula_doc_screen · defs · edit · lines · math · math_model · rows · sub · formula_doc.css) · 이미 안 부르던 796-2 수식 나무 화면(LogicLab_Equation · Defs · Var · css · M01_Logic_Equation_Model · sheet_formula_cell · ui_template_equation_defs · edit) 걷음 — 18 파일 4,860 줄 - formula_doc_render = 줄 쌓기가 쓰는 reach 만 · 옵션 가로 CSS 는 line_stack.css 로 - 남김 = 계산기 common_util_formula_doc* · 서버 길 · 옵션 · 사본 표지 · 합계 상자 · KaTeX · 시험 페이지 tools/line-stack-test - 시험: test_796_2 지움(걷은 화면 원본 검사뿐) · 799_1 · 800_2 걷은 화면 줄만 뺌 · 799_2 · 802_4b2 · 804_2 보기 · 화면 파일 대상 바꿈 · test_804_6 새로(걷은 파일 · 부르는 곳 없음 · 첫 탭 · 사본 할일) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,411 +0,0 @@
|
||||
/* 수식 문서 화면(802-3 · 803-2a) — 세 영역 가로(기호 식 수식 영역 | 값 식 | 기호 정의) · 맨 아래 합계 상자 · 색은 공통 값만 */
|
||||
.fdoc {
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.fdoc__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fdoc__options > * {
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
|
||||
.fdoc__sum {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__sumleft,
|
||||
.fdoc__tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__resultsym {
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
/* 세 영역 — 기호 식 · 값 식(격자 두 칸 · 줄마다 같은 높이) | 기호 정의 */
|
||||
.fdoc__areas {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.fdoc__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0 var(--spacing-24);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fdoc__side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 기호 식 = 하나의 수식 영역 · 값 식 = 같은 높이 줄 — 줄 칸이 이어진 한 상자씩 */
|
||||
.fdoc__area,
|
||||
.fdoc__val {
|
||||
min-height: 32px;
|
||||
padding: 2px var(--spacing-8);
|
||||
border-inline: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.fdoc__area {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.fdoc__area.is-top,
|
||||
.fdoc__val.is-top {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__area.is-bottom,
|
||||
.fdoc__val.is-bottom {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
padding-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__area.is-top.is-bottom,
|
||||
.fdoc__val.is-top.is-bottom {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.fdoc__blank {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.fdoc__blank.is-alone,
|
||||
.fdoc__blank.is-alone + .fdoc__val {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.fdoc__symhead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.fdoc__kindtag {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.fdoc__colhead {
|
||||
align-self: end;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fdoc__cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--spacing-4);
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.fdoc__sym,
|
||||
.fdoc__valcell {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.fdoc__edit {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--spacing-4);
|
||||
min-width: 2em;
|
||||
min-height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.fdoc__edit:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.fdoc__edit[data-editing] {
|
||||
outline: 1px solid var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.fdoc__edit[data-bad],
|
||||
.fdoc__money[data-bad] .fdoc__name {
|
||||
text-decoration: underline wavy var(--color-danger);
|
||||
}
|
||||
|
||||
.fdoc__raw {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.fdoc__atom.is-val {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fdoc__atom.is-val:hover {
|
||||
outline: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fdoc__atom.is-missing {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.fdoc__lit {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fdoc__lit[data-editing] {
|
||||
outline: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fdoc__dash {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 금액 줄 · 묶음 */
|
||||
.fdoc__money {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fdoc__valcell.fdoc__money {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fdoc__mrow {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.fdoc__name {
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.fdoc__name[data-editing] {
|
||||
outline: 1px solid var(--color-accent);
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.fdoc__cost,
|
||||
.fdoc__tk {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.fdoc__tk {
|
||||
margin-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__amount {
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
.fdoc__grouphead {
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__group {
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
.fdoc__total {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 기호 정의 — 오른쪽 영역 한 줄씩(머리 아래 한 상자) */
|
||||
.fdoc__defs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fdoc__defs > .fdoc__colhead {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fdoc__defs > .fdoc__colhead + * {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fdoc__side {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.fdoc__defrow {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.fdoc__defrow.is-open {
|
||||
background: var(--color-surface-sunken);
|
||||
padding-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__def {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: 2px var(--spacing-4);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.fdoc__def:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.fdoc__deflink {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.fdoc__defedit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.fdoc__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fdoc__fields > * {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.fdoc__fields .ui-find {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.fdoc__unused summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fdoc__sub {
|
||||
margin: var(--spacing-4) var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fdoc__wrap,
|
||||
.fdoc__val {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 수식 영역 편집기(803-2a · `formula_doc_math.ts`) — 치는 대로 조판 · 커서 · 숨은 글 칸 */
|
||||
.fdoc-math {
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* 편집 중 줄 = 그 줄 너비 전부(빈 줄도 입력 칸이 보임 · 줄은 넘칠 때만 꺾임) */
|
||||
.fdoc__edit.fdoc-math,
|
||||
.fdoc__edit.fdoc-math > .fdoc-math__view,
|
||||
.fdoc__edit.fdoc-math > .fdoc-math__view > .fdoc-math__row {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fdoc-math__view,
|
||||
.fdoc-math__row {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fdoc-math__view > .fdoc-math__row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fdoc-math .ui-eq__op {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.fdoc-math__latin {
|
||||
font-style: italic;
|
||||
font-family: "Cambria Math", "STIX Two Math", "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.fdoc-math__row.is-empty {
|
||||
min-width: 0.8em;
|
||||
min-height: 1.1em;
|
||||
outline: 1px dashed var(--color-border-strong);
|
||||
}
|
||||
|
||||
.fdoc-math__caret {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 1.2em;
|
||||
margin-right: -1.5px;
|
||||
border-left: 1.5px solid var(--color-text-body);
|
||||
animation: fdoc-math-blink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes fdoc-math-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fdoc-math__ime {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fdoc-math.is-all .fdoc-math__view {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.fdoc-math__sink {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_defs.ts
|
||||
* 수식 문서 기호 정의 영역(PLAN 802-3 · 요구 660-2 · 660-5 · 660-6 · 664) — 값 식 아래 「기호 : 국문 이름 — 연결」 한 줄씩
|
||||
* 같은 기호 한 번 · 값 · 단위 · DB 키 없음 · 지금 탄 갈래로 닿는 기호만(나머지는 접힌 「쓰지 않는 기호」)
|
||||
* 식에 새 기호를 쓰면 「정의 필요」 줄이 생김 → 눌러 연결 고름(그 줄 안에서 · 창 없음):
|
||||
* 숫자 · 선택(목록) · 입력(범위) · 요소(인력 · 재료 · 기계 찾기) · 표(표 · 조건 식 · 칸) · 단가산출(다른 기초 · 넘길 입력 · 칸) · 식(식 줄)
|
||||
* 기호 · 아래 · 위 첨자 · 국문 이름 · 단위 고침 · 정의 지움(쓰는 자리는 「정의 필요」로 남음)
|
||||
* 고친 것은 부른 쪽 훅으로 — 여기선 문서를 안 고침
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createFindList, type FindRow } from "@ui/ui_template_find_list";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { LINKS, splitId, type DocVar, type Link } from "../common_util/common_util_formula_doc";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import { symNode } from "./formula_doc_render";
|
||||
import { ft } from "./formula_doc_text";
|
||||
|
||||
const PLAIN = new Set(["적용", "값", "기준"]);
|
||||
const ELEM_KINDS = ["인력", "재료", "기계"];
|
||||
|
||||
export interface Pick {
|
||||
키: string;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
단위?: string;
|
||||
조건?: string[];
|
||||
칸?: string[];
|
||||
입력?: string[];
|
||||
}
|
||||
|
||||
export interface DefsHooks {
|
||||
admin: boolean;
|
||||
vars: Map<string, DocVar>;
|
||||
names: Map<string, string>;
|
||||
lines: Set<string>;
|
||||
shown: string[];
|
||||
unused: string[];
|
||||
need: Set<string>;
|
||||
open: string | null;
|
||||
onOpen: (id: string | null) => void;
|
||||
setLink: (id: string, link: Link) => void;
|
||||
setVar: (id: string, patch: Partial<DocVar>) => void;
|
||||
rename: (id: string, sign: string, low: string, up: string) => string | null;
|
||||
dropVar: (id: string) => void;
|
||||
makeLine: (id: string) => void;
|
||||
ask: (kind: string, words: string, group: string) => Promise<{ 후보: Pick[]; 더있음: boolean }>;
|
||||
table: (key: string) => Promise<DocTable | null>;
|
||||
subInputs: (key: string) => string[];
|
||||
sub: (id: string) => HTMLElement | null;
|
||||
}
|
||||
|
||||
const span = (cls: string, text?: string): HTMLSpanElement =>
|
||||
el("span", { className: cls, ...(text !== undefined ? { text } : {}) });
|
||||
|
||||
/** 연결 글 — 갈래 + 대상 이름(키 · 값 · 단위 없음) */
|
||||
export function linkText(
|
||||
v: DocVar | undefined,
|
||||
names: Map<string, string>,
|
||||
isLine: boolean,
|
||||
): string {
|
||||
const link = v?.연결;
|
||||
const kind = link?.갈래 ?? "식";
|
||||
if (kind === "식") return isLine ? "식" : ft("Need");
|
||||
const ref = String(link?.키 ?? link?.표 ?? "");
|
||||
const named = (r: string): string =>
|
||||
r.includes("{") ? r.replace(/^[A-Z]+:/, "").replace(/[{}]/g, "") : names.get(r) || "";
|
||||
const parts: string[] = [kind];
|
||||
if (kind === "요소" && link?.재료) {
|
||||
const m = link.재료 as Record<string, unknown>;
|
||||
parts.push(String(m.검색어 ?? m.이름 ?? m.상세구분 ?? m.구분 ?? ""));
|
||||
} else if (ref) parts.push(named(ref));
|
||||
if (link?.칸 && !PLAIN.has(String(link.칸))) parts.push(String(link.칸));
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function field(label: string, value: string, onChange: (v: string) => void, width = "") {
|
||||
const f = createInputField({ label, value, compact: true, size: 24 });
|
||||
f.input.addEventListener("change", () => onChange(f.input.value));
|
||||
if (width) f.root.style.width = width;
|
||||
return f;
|
||||
}
|
||||
|
||||
/** 찾아 고르기(요소 · 표 · 단가산출) */
|
||||
function finder(h: DefsHooks, kind: string, onPick: (p: Pick) => void, group = { now: "인력" }) {
|
||||
const filters: HTMLElement[] = [];
|
||||
if (kind === "요소")
|
||||
filters.push(
|
||||
createSegment({
|
||||
options: ELEM_KINDS.map((k) => ({ value: k, label: k })),
|
||||
value: group.now,
|
||||
size: 24,
|
||||
onChange: (v) => {
|
||||
group.now = v;
|
||||
void run();
|
||||
},
|
||||
}).root,
|
||||
);
|
||||
const list = createFindList({ placeholder: ft("Find"), filters });
|
||||
let timer = 0;
|
||||
let seq = 0;
|
||||
const run = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const words = list.search.value.trim();
|
||||
if (!words && kind !== "요소") return list.setRows([]);
|
||||
const got = await h
|
||||
.ask(kind === "단가산출" ? "단가산출" : kind, words, group.now)
|
||||
.catch(() => null);
|
||||
if (mine !== seq || !got) return;
|
||||
list.hint.textContent = got.더있음 ? ft("More") : got.후보.length ? "" : ft("Nothing");
|
||||
list.setRows(
|
||||
got.후보.map((p): FindRow => ({
|
||||
key: p.키,
|
||||
name: p.이름 ?? "",
|
||||
sub: p.규격 ?? "",
|
||||
unit: p.단위 ?? "",
|
||||
onPick: () => onPick(p),
|
||||
})),
|
||||
);
|
||||
};
|
||||
list.search.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void run(), 250);
|
||||
});
|
||||
return list.root;
|
||||
}
|
||||
|
||||
function kindBody(id: string, v: DocVar | undefined, h: DefsHooks): HTMLElement[] {
|
||||
const link: Link = { ...(v?.연결 ?? { 갈래: "식" }) };
|
||||
const kind = link.갈래 ?? "식";
|
||||
const set = (patch: Partial<Link>): void => h.setLink(id, { ...link, ...patch });
|
||||
const num = (s: string): number | string => {
|
||||
const n = Number(s.replace(/,/g, "").trim());
|
||||
return s.trim() !== "" && Number.isFinite(n) ? n : s.trim();
|
||||
};
|
||||
switch (kind) {
|
||||
case "숫자":
|
||||
return [field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root];
|
||||
case "선택": {
|
||||
const list = (link.목록 ?? []).map(String);
|
||||
const pick = createSelectField({
|
||||
label: ft("Default"),
|
||||
options: list.map((x) => ({ value: x, text: x })),
|
||||
value: String(link.값 ?? ""),
|
||||
compact: true,
|
||||
size: 24,
|
||||
onChange: (x) => set({ 값: link.목록?.find((y) => String(y) === x) ?? x }),
|
||||
});
|
||||
const items = field(ft("List"), list.join(" · "), (s) =>
|
||||
set({
|
||||
목록: s
|
||||
.split(/\s*[·,]\s*/)
|
||||
.filter(Boolean)
|
||||
.map((x) => num(x)),
|
||||
}),
|
||||
);
|
||||
return [items.root, pick.root];
|
||||
}
|
||||
case "입력": {
|
||||
const [low, high] = link.범위 ?? [null, null];
|
||||
const bound = (i: 0 | 1) => (s: string) => {
|
||||
const r: [number | null, number | null] = [...(link.범위 ?? [null, null])] as never;
|
||||
const n = num(s);
|
||||
r[i] = typeof n === "number" ? n : null;
|
||||
set(r[0] === null && r[1] === null ? { 범위: undefined } : { 범위: r });
|
||||
};
|
||||
return [
|
||||
field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root,
|
||||
field(ft("Low_Bound"), low === null ? "" : String(low), bound(0), "7em").root,
|
||||
field(ft("High_Bound"), high === null ? "" : String(high), bound(1), "7em").root,
|
||||
];
|
||||
}
|
||||
case "요소": {
|
||||
const out: HTMLElement[] = [];
|
||||
if (link.재료)
|
||||
out.push(
|
||||
span("ui-text ui-text--muted", `${ft("Material")} · ${linkText(v, h.names, false)}`),
|
||||
);
|
||||
out.push(
|
||||
finder(h, "요소", (p) => {
|
||||
h.names.set(p.키, [p.이름, p.규격].filter(Boolean).join(" "));
|
||||
h.setLink(id, { 갈래: "요소", 키: p.키, ...(link.지역 ? { 지역: link.지역 } : {}) });
|
||||
}),
|
||||
field(ft("Column"), String(link.칸 ?? ""), (s) => set({ 칸: s.trim() || undefined }), "9em")
|
||||
.root,
|
||||
field(
|
||||
ft("Region"),
|
||||
String(link.지역 ?? ""),
|
||||
(s) => set({ 지역: s.trim() || undefined }),
|
||||
"9em",
|
||||
).root,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
case "표": {
|
||||
const out: HTMLElement[] = [
|
||||
finder(h, "표", (p) => {
|
||||
h.names.set(p.키, p.이름 ?? "");
|
||||
void h
|
||||
.table(p.키)
|
||||
.then(() => h.setLink(id, { 갈래: "표", 표: p.키, 조건: {}, 칸: p.칸?.[0] ?? "" }));
|
||||
}),
|
||||
];
|
||||
const tbl = link.표 ? h.table(String(link.표)) : null;
|
||||
const cols = el("div", { className: "fdoc__fields" });
|
||||
out.push(cols);
|
||||
void tbl?.then((t) => {
|
||||
if (!t) return;
|
||||
const names = [
|
||||
...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]),
|
||||
].filter((k) => !(k in (t.조건 ?? {})));
|
||||
cols.append(
|
||||
createSelectField({
|
||||
label: ft("Column"),
|
||||
options: names.map((x) => ({ value: x, text: x })),
|
||||
value: String(link.칸 ?? ""),
|
||||
compact: true,
|
||||
size: 24,
|
||||
onChange: (x) => set({ 칸: x }),
|
||||
}).root,
|
||||
...Object.keys(t.조건 ?? {}).map((c) => {
|
||||
const seen = [...new Set(t.줄.map((r) => r[c]).filter((x) => typeof x === "string"))];
|
||||
const f = field(`${ft("Cond")} ${c}`, String(link.조건?.[c] ?? ""), (s) => {
|
||||
const box = { ...(link.조건 ?? {}) };
|
||||
if (s.trim()) box[c] = s.trim();
|
||||
else delete box[c];
|
||||
set({ 조건: box });
|
||||
});
|
||||
f.input.placeholder = seen
|
||||
.slice(0, 4)
|
||||
.map((x) => `'${x}'`)
|
||||
.join(" · ");
|
||||
return f.root;
|
||||
}),
|
||||
);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
case "단가산출": {
|
||||
const ins = link.키 ? h.subInputs(String(link.키)) : [];
|
||||
return [
|
||||
finder(h, "단가산출", (p) => {
|
||||
h.names.set(p.키, p.이름 ?? "");
|
||||
h.setLink(id, { 갈래: "단가산출", 키: p.키, 입력: {} });
|
||||
}),
|
||||
createSelectField({
|
||||
label: ft("Column"),
|
||||
options: [
|
||||
{ value: "", text: ft("None") },
|
||||
...["노무비", "재료비", "경비", "계"].map((x) => ({ value: x, text: x })),
|
||||
],
|
||||
value: String(link.칸 ?? ""),
|
||||
compact: true,
|
||||
size: 24,
|
||||
onChange: (x) => set({ 칸: x || undefined }),
|
||||
}).root,
|
||||
...ins.map(
|
||||
(k) =>
|
||||
field(`${ft("Inputs")} ${k}`, String(link.입력?.[k] ?? ""), (s) => {
|
||||
const box = { ...(link.입력 ?? {}) };
|
||||
if (s.trim()) box[k] = s.trim();
|
||||
else delete box[k];
|
||||
set({ 입력: box });
|
||||
}).root,
|
||||
),
|
||||
];
|
||||
}
|
||||
default:
|
||||
return h.lines.has(id)
|
||||
? [span("ui-text ui-text--muted", ft("By_Line"))]
|
||||
: [
|
||||
createButton({
|
||||
label: ft("Make_Line"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => h.makeLine(id),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function editor(id: string, h: DefsHooks): HTMLElement {
|
||||
const v = h.vars.get(id);
|
||||
const [sign, low, up] = v ? [v.기호 ?? "", v.아래 ?? "", v.위 ?? ""] : splitId(id);
|
||||
const box = el("div", { className: "fdoc__defedit" });
|
||||
const names = el("div", { className: "fdoc__fields" });
|
||||
const sym = { sign, low, up };
|
||||
const signF = field(ft("Sign"), sign, (s) => tryRename({ ...sym, sign: s.trim() }), "6em");
|
||||
const lowF = field(ft("Low"), low, (s) => tryRename({ ...sym, low: s.trim() }), "8em");
|
||||
const upF = field(ft("Up"), up, (s) => tryRename({ ...sym, up: s.trim() }), "6em");
|
||||
const tryRename = (now: typeof sym): void => {
|
||||
const why = h.rename(id, now.sign, now.low, now.up);
|
||||
if (why) signF.setError(why);
|
||||
};
|
||||
names.append(
|
||||
signF.root,
|
||||
lowF.root,
|
||||
upF.root,
|
||||
field(ft("Name"), v?.이름 ?? "", (s) => h.setVar(id, { 이름: s.trim() })).root,
|
||||
field(ft("Unit"), v?.단위 ?? "", (s) => h.setVar(id, { 단위: s.trim() }), "7em").root,
|
||||
);
|
||||
const kind = v?.연결?.갈래 ?? "식";
|
||||
const kinds = createSegment({
|
||||
options: LINKS.map((k) => ({ value: k, label: k })),
|
||||
value: kind,
|
||||
size: 24,
|
||||
ariaLabel: ft("Link"),
|
||||
onChange: (k) => {
|
||||
const base: Link = { 갈래: k };
|
||||
if (k === "숫자" || k === "입력") base.값 = "";
|
||||
if (k === "선택") Object.assign(base, { 목록: [], 값: "" });
|
||||
h.setLink(id, base);
|
||||
if (k === "식" && !h.lines.has(id)) h.makeLine(id);
|
||||
},
|
||||
});
|
||||
const body = el("div", { className: "fdoc__fields", children: kindBody(id, v, h) });
|
||||
const drop = createButton({
|
||||
label: ft("Drop_Var"),
|
||||
variant: "danger-ghost",
|
||||
size: 24,
|
||||
onClick: () => h.dropVar(id),
|
||||
});
|
||||
box.append(names, kinds.root, body, drop);
|
||||
return box;
|
||||
}
|
||||
|
||||
function row(id: string, h: DefsHooks): HTMLElement {
|
||||
const v = h.vars.get(id);
|
||||
const need = h.need.has(id);
|
||||
const head = el("div", { className: "fdoc__def", attrs: { "data-def": id } });
|
||||
const sym = span("fdoc__defsym");
|
||||
sym.append(symNode(id));
|
||||
head.append(sym, span("ui-eq__op", ":"));
|
||||
if (need) head.append(createBadge(ft("Need"), "warning"));
|
||||
else head.append(span("fdoc__defname", v?.이름 || ""));
|
||||
const own = v?.이름 ?? "";
|
||||
// 이름에 이미 든 대상 이름 · 칸은 연결 글에서 뺌(같은 말 두 번 없음)
|
||||
const text = linkText(v, h.names, h.lines.has(id))
|
||||
.split(" · ")
|
||||
.filter((x, i) => i === 0 || !own.includes(x))
|
||||
.join(" · ");
|
||||
if (!need) head.append(span("ui-text ui-text--muted fdoc__deflink", `— ${text}`));
|
||||
const wrap = el("div", { className: "fdoc__defrow", children: [head] });
|
||||
if (h.open === id) wrap.classList.add("is-open");
|
||||
head.addEventListener("click", () => h.onOpen(h.open === id ? null : id));
|
||||
if (h.open === id) {
|
||||
const sub = h.sub(id);
|
||||
if (sub) wrap.append(sub);
|
||||
if (h.admin) wrap.append(editor(id, h));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 정의 영역 — 닿는 기호 줄 + 접힌 쓰지 않는 기호 */
|
||||
export function buildDefs(h: DefsHooks): HTMLElement {
|
||||
const box = el("div", { className: "fdoc__defs" });
|
||||
box.append(el("div", { className: "fdoc__colhead", text: ft("Defs_Head") }));
|
||||
h.shown.forEach((id) => box.append(row(id, h)));
|
||||
if (h.unused.length) {
|
||||
const fold = el("details", {
|
||||
className: "fdoc__unused",
|
||||
attrs: h.open && h.unused.includes(h.open) ? { open: "" } : {},
|
||||
children: [el("summary", { text: `${ft("Unused")} ${h.unused.length}` })],
|
||||
});
|
||||
h.unused.forEach((id) => fold.append(row(id, h)));
|
||||
box.append(fold);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_edit.ts
|
||||
* 수식 문서 고침(PLAN 802-3 · 802-4b-2) — 식 줄 · 금액 줄 · 값 식 숫자 고침을 문서에 얹음(화면 · fetch 없음)
|
||||
* 수식 화면(`formula_doc_screen.ts`)과 끊은 단가산출 블록(`formula_doc_sub.ts`)이 같은 고침을 씀
|
||||
* 수식 영역 한 줄 글(803-2a) = `editRow`(식 줄 · 금액 줄 「노무비 형틀목공 = w × Q」) · 빈 줄 걷기 `sweep`
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
goodId,
|
||||
splitId,
|
||||
textOf,
|
||||
tidy,
|
||||
varId,
|
||||
type DocVar,
|
||||
type FormulaDoc,
|
||||
type MoneyLine,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { swapLit, tryParse } from "./formula_doc_render";
|
||||
|
||||
/** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */
|
||||
export function splitLine(text: string): [string | null, string] {
|
||||
const m = /^([^=<>!]*?)\s*(?<![<>!=])=(?!=)\s*([\s\S]*)$/.exec(text);
|
||||
if (!m) return [null, text.trim()];
|
||||
return [m[1].trim(), m[2].trim()];
|
||||
}
|
||||
|
||||
export const tidyText = (t: string): string => (t.trim() ? tidy(t.trim()) : "");
|
||||
|
||||
/** 기호 정의 — 없으면 「식」 갈래로 새로 */
|
||||
export function ensureVar(d: FormulaDoc, id: string): DocVar {
|
||||
const have = (d.변수 ??= []).find((v) => varId(v) === id);
|
||||
if (have) return have;
|
||||
const [기호, 아래, 위] = splitId(id);
|
||||
const v: DocVar = { 열쇠: id, 기호, 아래, 위, 이름: "", 단위: "", 연결: { 갈래: "식" } };
|
||||
d.변수.push(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 식 줄 i 를 글로 — 빈 글 = 줄 지움 · 머리 바뀜 = 식 갈래 정의도 같이 */
|
||||
export function editLine(d: FormulaDoc, i: number, text: string): void {
|
||||
const lines = (d.식 ??= []);
|
||||
const line = lines[i];
|
||||
if (!line) return;
|
||||
if (!text.trim()) {
|
||||
lines.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
const [h, body] = splitLine(text);
|
||||
const old = String(line.기호 ?? "");
|
||||
if (h !== null && h !== old) {
|
||||
line.기호 = h;
|
||||
const was = (d.변수 ?? []).find((v) => varId(v) === old && (v.연결?.갈래 ?? "식") === "식");
|
||||
if (was && goodId(h) && !(d.변수 ?? []).some((v) => varId(v) === h)) {
|
||||
const [기호, 아래, 위] = splitId(h);
|
||||
Object.assign(was, { 기호, 아래, 위 });
|
||||
}
|
||||
}
|
||||
line.식 = tidyText(body);
|
||||
}
|
||||
|
||||
/** 금액 줄 i 고침(null = 지움) — 단가 · 수량 글은 다듬음 · 빈 수량 = 칸 뺌 */
|
||||
export function editMoney(d: FormulaDoc, i: number, patch: Partial<MoneyLine> | null): void {
|
||||
const lines = (d.금액 ??= []);
|
||||
if (!patch) {
|
||||
lines.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
const line = lines[i];
|
||||
if (!line) return;
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
const text = typeof v === "string" && (k === "단가" || k === "수량") ? tidyText(v) : v;
|
||||
if (k === "수량" && text === "") delete line.수량;
|
||||
else (line as Record<string, unknown>)[k] = text;
|
||||
}
|
||||
}
|
||||
|
||||
/** 값 식 숫자 고침 — 식 줄 · 금액 줄 단가 · 수량 글의 at 번째 수 */
|
||||
export function editLit(
|
||||
d: FormulaDoc,
|
||||
target: "line" | "price" | "qty",
|
||||
i: number,
|
||||
at: number,
|
||||
value: number,
|
||||
): void {
|
||||
if (target === "line") {
|
||||
const line = d.식?.[i];
|
||||
if (line) line.식 = swapLit(String(line.식 ?? ""), at, value);
|
||||
return;
|
||||
}
|
||||
const line = d.금액?.[i];
|
||||
if (!line) return;
|
||||
const slot = target === "price" ? "단가" : "수량";
|
||||
line[slot] = swapLit(String(line[slot] ?? ""), at, value);
|
||||
}
|
||||
|
||||
const KINDS = ["노무비", "재료비", "경비", "묶음"];
|
||||
|
||||
/** 금액 줄 몸 → 단가 · 수량(맨 위 × 의 오른쪽 = 수량 · 없으면 단가만) */
|
||||
function splitMoney(body: string): { 단가: string; 수량: string } {
|
||||
const n = tryParse(body);
|
||||
if (n && n[0] === "bin" && n[1] === "*") return { 단가: textOf(n[2]), 수량: textOf(n[3]) };
|
||||
return { 단가: body, 수량: "" };
|
||||
}
|
||||
|
||||
/** 수식 영역 한 줄 글 → 문서(803-2a) — 머리 첫 말이 비목이면 금액 줄 · 아니면 식 줄(갈래가 바뀌면 옮김)
|
||||
* 빈 글 = 줄 지움 · 돌려줌 = 고친 뒤 그 줄 객체(지웠으면 null) */
|
||||
export function editRow(
|
||||
d: FormulaDoc,
|
||||
kind: "line" | "money",
|
||||
i: number,
|
||||
text: string,
|
||||
): object | null {
|
||||
const lines = (d.식 ??= []);
|
||||
const money = (d.금액 ??= []);
|
||||
const was = kind === "line" ? lines[i] : money[i];
|
||||
if (!was) return null;
|
||||
if (!text.trim()) {
|
||||
(kind === "line" ? lines : money).splice(i, 1);
|
||||
return null;
|
||||
}
|
||||
const [h, body] = splitLine(text);
|
||||
const words = (h ?? text).trim().split(/\s+/);
|
||||
const cost = KINDS.includes(words[0]) ? words[0] : null;
|
||||
if (!cost) {
|
||||
if (kind === "line") {
|
||||
editLine(d, i, text);
|
||||
return lines[i];
|
||||
}
|
||||
money.splice(i, 1);
|
||||
lines.push({ 기호: "", 식: "" });
|
||||
editLine(d, lines.length - 1, text);
|
||||
return lines[lines.length - 1];
|
||||
}
|
||||
const patch = {
|
||||
비목: cost,
|
||||
이름: words.slice(1).join(" "),
|
||||
...splitMoney(h === null ? "" : body),
|
||||
};
|
||||
if (kind === "money") {
|
||||
editMoney(d, i, patch);
|
||||
return money[i];
|
||||
}
|
||||
lines.splice(i, 1);
|
||||
const group = money.length ? money[money.length - 1].묶음 : undefined;
|
||||
money.push({ 이름: "", 비목: cost, 단가: "", ...(group ? { 묶음: group } : {}) });
|
||||
editMoney(d, money.length - 1, patch);
|
||||
return money[money.length - 1];
|
||||
}
|
||||
|
||||
/** 빈 줄 걷기(`keep` 빼고) */
|
||||
export function sweep(d: FormulaDoc, keep?: object | null): void {
|
||||
d.식 = (d.식 ?? []).filter((l) => l === keep || String(l.기호 ?? "") || String(l.식 ?? ""));
|
||||
d.금액 = (d.금액 ?? []).filter((l) => l === keep || l.이름 || l.단가 || l.수량);
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_lines.ts
|
||||
* 수식 문서 줄(PLAN 802-3 · 803-2a · 요구 660~667) — 왼쪽 「기호 식」 = 줄바꿈으로 이어진 하나의 수식 영역 · 가운데 「값 식」 같은 높이
|
||||
* 식 줄: `Q = 3600 × q … ÷ Cm` · 값 식 `= 값 식 = 결과 단위`
|
||||
* 금액 줄: `노무비 형틀목공 = w × Q`(비목 · 이름 = 단가 × 수량) · 값 식 `= 값 × 값 = 금액`(묶음 = 노 · 재 · 경 세 줄)
|
||||
* 묶음: 하나 = 머리 없음 · 둘 이상 = 「가. 이름」 머리 + 「소계」 · 맨 끝 합은 화면 맨 아래 합계 상자
|
||||
* 왼쪽 줄 누름 = 그 자리 편집기(`formula_doc_math.ts` · 부른 쪽 `mount`) · 영역 맨 아래 빈 줄 = 새 줄
|
||||
* 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 그 밖 = 그 줄 편집
|
||||
* 고친 것은 부른 쪽(`formula_doc_screen.ts`) 훅으로 — 여기선 문서를 안 고침
|
||||
* ========================================================================== */
|
||||
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { editNumber, serializeEq, tidyEq } from "@ui/ui_template_equation_edit";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { DocVar, FormulaDoc, MoneyLine, Node } from "../common_util/common_util_formula_doc";
|
||||
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import { moneyScope } from "../common_util/common_util_formula_doc_view";
|
||||
import {
|
||||
lineMap,
|
||||
litsOf,
|
||||
offLine,
|
||||
renderNode,
|
||||
symNode,
|
||||
takenOf,
|
||||
tryParse,
|
||||
valueNode,
|
||||
type Values,
|
||||
} from "./formula_doc_render";
|
||||
import { ft } from "./formula_doc_text";
|
||||
|
||||
export const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const ORDER = "가나다라마바사아자차카타파하";
|
||||
|
||||
/** 줄 자리 — 식 줄 · 금액 줄 차례 */
|
||||
export interface RowRef {
|
||||
kind: "line" | "money";
|
||||
i: number;
|
||||
}
|
||||
|
||||
export interface LinesHooks {
|
||||
admin: boolean;
|
||||
/** 선택으로 꺼진 금액 줄도 보임(고침 모드) */
|
||||
showOff?: boolean;
|
||||
doc: FormulaDoc;
|
||||
answer: DocAnswer;
|
||||
vars: Map<string, DocVar>;
|
||||
/** 기호 · 금액 줄 이름 → 표지 글 */
|
||||
marks: Map<string, string>;
|
||||
/** 편집 중인 줄 */
|
||||
editing: RowRef | null;
|
||||
/** 편집기를 그 줄에 엶(글 = 선형 꼴) */
|
||||
mount: (host: HTMLElement, ref: RowRef, text: string) => void;
|
||||
/** 줄 누름 — 편집 시작(`end` = 영역 맨 아래 새 줄) */
|
||||
onEditAt: (ref: RowRef | "end", at?: { x: number; y: number }) => void;
|
||||
onLit: (target: "line" | "price" | "qty", i: number, at: number, value: number) => void;
|
||||
onPickVar: (id: string, number?: HTMLElement) => void;
|
||||
onGroupName: (old: string, now: string) => void;
|
||||
onRedraw: () => void;
|
||||
}
|
||||
|
||||
const span = (cls: string, text?: string): HTMLSpanElement =>
|
||||
el("span", { className: cls, ...(text !== undefined ? { text } : {}) });
|
||||
|
||||
const memo = new WeakMap<DocAnswer, Values>();
|
||||
/** 기호 값 + 금액 줄 기호(그 줄 금액 — 뒤 금액 줄이 부름) */
|
||||
function vals(h: LinesHooks): Values {
|
||||
let got = memo.get(h.answer);
|
||||
if (!got) {
|
||||
got = { ...(h.answer.값 ?? {}) };
|
||||
const outs = (h.answer.줄 ?? []) as { 금액?: number | null }[];
|
||||
(h.doc.금액 ?? []).forEach((l, i) => {
|
||||
if (l.기호 && typeof outs[i]?.금액 === "number") got![String(l.기호)] = outs[i].금액;
|
||||
});
|
||||
memo.set(h.answer, got);
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 식 글 → 그림(못 풀면 글 그대로 빨강) · scope = 금액 줄이 부르는 비목 합(앞 줄 누계) */
|
||||
function expr(
|
||||
text: unknown,
|
||||
h: LinesHooks,
|
||||
side: "sym" | "val",
|
||||
inline = false,
|
||||
cost?: string,
|
||||
scope?: Values,
|
||||
) {
|
||||
const n = tryParse(text);
|
||||
if (!n) {
|
||||
const raw = span("fdoc__raw", String(text ?? ""));
|
||||
if (String(text ?? "").trim()) raw.dataset.bad = "1";
|
||||
return { node: raw, tree: null as Node | null };
|
||||
}
|
||||
const lits = side === "val" ? litsOf(n) : undefined;
|
||||
return {
|
||||
node: renderNode(n, {
|
||||
side,
|
||||
vals: scope ? { ...vals(h), ...scope } : vals(h),
|
||||
vars: h.vars,
|
||||
inline,
|
||||
cost,
|
||||
lits,
|
||||
}),
|
||||
tree: n,
|
||||
};
|
||||
}
|
||||
|
||||
const filled = (t: unknown): boolean => t !== null && t !== undefined && String(t).trim() !== "";
|
||||
|
||||
/** 금액 줄 단가 × 수량 한 식 글 */
|
||||
export const moneyBody = (l: MoneyLine): string =>
|
||||
filled(l.수량) ? `(${String(l.단가 ?? "")}) × (${String(l.수량)})` : String(l.단가 ?? "");
|
||||
|
||||
/** 왼쪽 칸 — 누름 = 편집 · 편집 중이면 편집기 */
|
||||
function areaCell(h: LinesHooks, ref: RowRef, edit: HTMLElement, text: () => string): HTMLElement {
|
||||
const left = el("div", {
|
||||
className: "fdoc__cell fdoc__sym",
|
||||
attrs: { "data-row": `${ref.kind}${ref.i}` },
|
||||
children: [edit],
|
||||
});
|
||||
if (!h.admin) return left;
|
||||
if (h.editing && h.editing.kind === ref.kind && h.editing.i === ref.i) {
|
||||
left.classList.add("is-editing");
|
||||
h.mount(edit, ref, text());
|
||||
} else
|
||||
left.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
h.onEditAt(ref, { x: ev.clientX, y: ev.clientY });
|
||||
});
|
||||
return left;
|
||||
}
|
||||
|
||||
/** 값 식 누름 — 수 = 그 자리 값 · 기호 값 = 정의 줄 · 그 밖 = 그 줄 편집 */
|
||||
function onValueClick(
|
||||
ev: MouseEvent,
|
||||
host: HTMLElement,
|
||||
h: LinesHooks,
|
||||
target: "line" | "price" | "qty",
|
||||
i: number,
|
||||
ref: RowRef,
|
||||
): void {
|
||||
if (!h.admin || host.dataset.editing) return;
|
||||
const t = ev.target as HTMLElement;
|
||||
const lit = t.closest<HTMLElement>("[data-lit]");
|
||||
const atom = t.closest<HTMLElement>("[data-var]");
|
||||
if (lit && host.contains(lit)) {
|
||||
ev.stopPropagation();
|
||||
editNumber(lit, (text) => {
|
||||
const v = Number(text.replace(/,/g, ""));
|
||||
if (Number.isFinite(v)) h.onLit(target, i, Number(lit.dataset.lit), v);
|
||||
else h.onRedraw();
|
||||
});
|
||||
} else if (atom && host.contains(atom)) {
|
||||
ev.stopPropagation();
|
||||
h.onPickVar(atom.dataset.var!, atom.querySelector<HTMLElement>(".ui-eq__num") ?? undefined);
|
||||
} else h.onEditAt(ref);
|
||||
}
|
||||
|
||||
function lineRow(i: number, h: LinesHooks): HTMLElement[] {
|
||||
const line = (h.doc.식 ?? [])[i];
|
||||
const ref: RowRef = { kind: "line", i };
|
||||
const head = String(line.기호 ?? "");
|
||||
const edit = span("fdoc__edit");
|
||||
if (head) {
|
||||
const a = span("ui-eq__var fdoc__atom fdoc__head");
|
||||
a.dataset.tok = head;
|
||||
a.append(symNode(head));
|
||||
edit.append(a, span("ui-eq__op", "="));
|
||||
}
|
||||
edit.append(expr(line.식, h, "sym").node);
|
||||
const bad = h.marks.get(head);
|
||||
if (bad) {
|
||||
edit.dataset.bad = "1";
|
||||
edit.title = bad;
|
||||
}
|
||||
const left = areaCell(h, ref, edit, () => tidyEq(serializeEq(edit)));
|
||||
const right = el("div", {
|
||||
className: "fdoc__cell fdoc__valcell",
|
||||
attrs: { "data-line": String(i) },
|
||||
});
|
||||
const vexpr = span("fdoc__edit");
|
||||
vexpr.append(expr(line.식, h, "val").node);
|
||||
const got = vals(h)[head];
|
||||
right.append(span("ui-eq__op", "="), vexpr);
|
||||
// 수 하나 · 기호 하나(탄 갈래) 줄 = 값 한 번만(「= 1 = 1」 없음)
|
||||
let eff = tryParse(line.식);
|
||||
for (let pick = eff ? takenOf(eff, vals(h)) : 0; eff && pick; pick = takenOf(eff, vals(h)))
|
||||
eff = (eff as ["call", string, Node[]])[2][pick];
|
||||
const single = !!eff && (eff[0] === "num" || (eff[0] === "var" && vals(h)[eff[1]] === got));
|
||||
if (single) {
|
||||
/* 값 식이 곧 결과 */
|
||||
} else if (got !== null && got !== undefined && typeof got !== "object") {
|
||||
right.append(span("ui-eq__op", "="), valueNode(got, unitOf(head, h)));
|
||||
} else right.append(span("ui-eq__op", "="), span("fdoc__dash", "—"));
|
||||
if (h.admin) vexpr.addEventListener("click", (ev) => onValueClick(ev, vexpr, h, "line", i, ref));
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
const unitOf = (id: string, h: LinesHooks): string =>
|
||||
h.vars.get(id)?.단위 || (h.doc.결과 === id ? (h.doc.결과단위 ?? "") : "");
|
||||
|
||||
function moneyRow(i: number, h: LinesHooks): HTMLElement[] {
|
||||
const line = (h.doc.금액 ?? [])[i];
|
||||
const ref: RowRef = { kind: "money", i };
|
||||
const got = (h.answer.줄 ?? [])[i] as
|
||||
{ 비목?: Record<string, number>; 금액?: number | null; 까닭?: string } | undefined;
|
||||
const kind = line.비목 ?? "노무비";
|
||||
const name = line.이름 ?? "";
|
||||
const body = expr(moneyBody(line), h, "sym", true).node;
|
||||
const edit = span("fdoc__edit fdoc__money");
|
||||
edit.append(span("fdoc__kindtag", kind), span("fdoc__name", name), span("ui-eq__op", "="), body);
|
||||
const bad = h.marks.get(name);
|
||||
if (bad) {
|
||||
edit.dataset.bad = "1";
|
||||
edit.title = bad;
|
||||
}
|
||||
const left = areaCell(h, ref, edit, () => tidyEq(`${kind} ${name} = ${serializeEq(body)}`));
|
||||
const right = el("div", { className: "fdoc__cell fdoc__valcell fdoc__money" });
|
||||
const costs = kind === "묶음" ? [...COSTS] : [null];
|
||||
const scope = moneyScope(h.answer.줄, i);
|
||||
const hasQty = filled(line.수량);
|
||||
for (const cost of costs) {
|
||||
const row = span("fdoc__mrow");
|
||||
if (cost) row.append(span("fdoc__cost", cost), span("ui-eq__op", ":"));
|
||||
const pv = span("fdoc__edit");
|
||||
pv.append(expr(line.단가, h, "val", true, cost ?? undefined, scope).node);
|
||||
row.append(pv);
|
||||
if (hasQty) {
|
||||
const qv = span("fdoc__edit");
|
||||
qv.append(expr(line.수량, h, "val", true, undefined, scope).node);
|
||||
row.append(span("ui-eq__op", "×"), qv);
|
||||
if (h.admin) qv.addEventListener("click", (ev) => onValueClick(ev, qv, h, "qty", i, ref));
|
||||
}
|
||||
if (h.admin) pv.addEventListener("click", (ev) => onValueClick(ev, pv, h, "price", i, ref));
|
||||
const amount = cost ? got?.비목?.[cost] : got?.금액;
|
||||
row.append(span("ui-eq__op", "="));
|
||||
if (typeof amount === "number") row.append(span("ui-eq__num fdoc__amount", eqText(amount)));
|
||||
else {
|
||||
const dash = span("fdoc__dash", "—");
|
||||
if (got?.까닭) dash.title = got.까닭;
|
||||
row.append(dash);
|
||||
}
|
||||
right.append(row);
|
||||
}
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/** 이름 그 자리 고침(글만) */
|
||||
function editName(node: HTMLElement, before: string, done: (text: string) => void): void {
|
||||
node.textContent = before;
|
||||
node.contentEditable = "true";
|
||||
node.dataset.editing = "1";
|
||||
let over = false;
|
||||
const finish = (keep: boolean): void => {
|
||||
if (over) return;
|
||||
over = true;
|
||||
node.contentEditable = "false";
|
||||
const text = (node.textContent ?? "").trim();
|
||||
if (keep && text !== before) done(text);
|
||||
else node.textContent = before || ft("Unnamed");
|
||||
delete node.dataset.editing;
|
||||
};
|
||||
node.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
finish(ev.key === "Enter");
|
||||
}
|
||||
});
|
||||
node.addEventListener("blur", () => finish(true), { once: true });
|
||||
queueMicrotask(() => {
|
||||
node.focus();
|
||||
window.getSelection()?.selectAllChildren(node);
|
||||
});
|
||||
}
|
||||
|
||||
/** 소계 · 계 줄(왼쪽 이름 · 가운데 노 · 재 · 경 · 계) */
|
||||
export function sumRow(label: string, b: Record<string, unknown> | null): HTMLElement[] {
|
||||
const left = el("div", {
|
||||
className: "fdoc__cell fdoc__sym fdoc__total",
|
||||
children: [span("fdoc__name", label)],
|
||||
});
|
||||
const right = el("div", { className: "fdoc__cell fdoc__total" });
|
||||
for (const k of [...COSTS, "계"]) {
|
||||
const v = b?.[k];
|
||||
right.append(
|
||||
span("fdoc__tk", k),
|
||||
typeof v === "number" ? span("ui-eq__num fdoc__amount", eqText(v)) : span("fdoc__dash", "—"),
|
||||
);
|
||||
}
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/** 선택으로 꺼진 금액 줄 차례 */
|
||||
export function offLines(doc: FormulaDoc, answer: DocAnswer): Set<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);
|
||||
(h.doc.식 ?? []).forEach((_, i) => {
|
||||
add(lineRow(i, h));
|
||||
order.push({ kind: "line", i });
|
||||
});
|
||||
const lines = h.doc.금액 ?? [];
|
||||
const groups: string[] = [];
|
||||
lines.forEach((l) => groups.includes(l.묶음 ?? "") || groups.push(l.묶음 ?? ""));
|
||||
const off = offLines(h.doc, h.answer);
|
||||
const seen = (i: number): boolean => !!h.showOff || !off.has(i);
|
||||
const many = groups.length > 1;
|
||||
const bundles = (h.answer.묶음 ?? []) as Record<string, unknown>[];
|
||||
const done = h.answer.셈됨;
|
||||
groups.forEach((g, gi) => {
|
||||
if (!lines.some((l, i) => (l.묶음 ?? "") === g && seen(i))) return; // 다 꺼진 묶음
|
||||
if (many) {
|
||||
const head = span("fdoc__group", `${ORDER[gi] ?? gi + 1}. `);
|
||||
const label = span("fdoc__name", g || ft("Unnamed"));
|
||||
head.append(label);
|
||||
if (h.admin)
|
||||
label.addEventListener("click", () => editName(label, g, (t) => h.onGroupName(g, t)));
|
||||
add([
|
||||
el("div", { className: "fdoc__cell fdoc__sym fdoc__grouphead", children: [head] }),
|
||||
el("div"),
|
||||
]);
|
||||
}
|
||||
lines.forEach((l, i) => {
|
||||
if ((l.묶음 ?? "") !== g || !seen(i)) return;
|
||||
add(moneyRow(i, h));
|
||||
order.push({ kind: "money", i });
|
||||
});
|
||||
if (many) add(sumRow(ft("Subtotal"), done ? (bundles[gi] ?? null) : null));
|
||||
});
|
||||
if (h.admin || !out.length) {
|
||||
// 영역 맨 아래 빈 줄 — 누르면 새 줄(빈 문서 = 빈 영역)
|
||||
const blank = el("div", { className: "fdoc__cell fdoc__sym fdoc__blank" });
|
||||
if (!out.length) blank.classList.add("is-alone");
|
||||
if (h.admin)
|
||||
blank.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
h.onEditAt("end");
|
||||
});
|
||||
add([blank, el("div")]);
|
||||
}
|
||||
// 칸 테 — 기호 식 · 값 식 영역마다 맨 위 · 맨 아래
|
||||
for (const k of [0, 1]) {
|
||||
const col = out.filter((_, n) => n % 2 === k);
|
||||
col.forEach((c) => c.classList.add(k ? "fdoc__val" : "fdoc__area"));
|
||||
col[0]?.classList.add("is-top");
|
||||
col[col.length - 1]?.classList.add("is-bottom");
|
||||
}
|
||||
return { cells: out, order };
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_math.ts
|
||||
* 수식 영역 그 자리 편집기(PLAN 803-2a · 요구 667-4 · 6) — 발표 자료 수식처럼 치는 대로 조판(꼴 = `formula_doc_math_model.ts`)
|
||||
* 누른 줄이 편집기로 바뀜 · 누른 자리에 커서 · 숨은 글 칸(한글 조합 · 붙여넣기)이 키를 받음
|
||||
* ← → = 구조 안팎 · ↑ ↓ = 분수 위아래 → 밖이면 윗줄 · 아랫줄 · Enter = 새 줄 · Esc · 다른 데 누름 = 나옴 · Ctrl+A = 줄 전체
|
||||
* 도구 줄(`mathToolbar`) = 편집 중일 때만 영역 위 — 분수 · 첨자 · 괄호 · × ÷ % · ≤ ≥ ≠ · 만약 · 반올림
|
||||
* 고친 글은 나올 때 부른 쪽 `onLeave(글, 어떻게)` 로 — 여기선 문서를 안 고침
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import {
|
||||
backspace,
|
||||
del,
|
||||
fromLinear,
|
||||
isOp,
|
||||
left,
|
||||
right,
|
||||
toLinear,
|
||||
typeChar,
|
||||
vertical,
|
||||
type Caret,
|
||||
type Item,
|
||||
} from "./formula_doc_math_model";
|
||||
import { ft, type TextKey } from "./formula_doc_text";
|
||||
|
||||
export type Leave = "exit" | "enter" | "up" | "down" | "back";
|
||||
|
||||
export interface MathHooks {
|
||||
/** 처음 글(선형 · 공용 `serializeEq` 꼴) */
|
||||
text: string;
|
||||
/** 커서 — 누른 자리 · 맨 앞 · 맨 끝(기본) */
|
||||
at?: { x: number; y?: number } | "start" | "end";
|
||||
onInput?: (text: string) => void;
|
||||
/** 나옴 — 글 · 어떻게 · 커서 가로 자리(위아래 줄 옮김) */
|
||||
onLeave: (text: string, how: Leave, x?: number) => void;
|
||||
}
|
||||
|
||||
export interface MathEditor {
|
||||
text: () => string;
|
||||
/** 부른 쪽이 닫음(훅 안 부름) */
|
||||
close: () => void;
|
||||
command: (name: Command) => void;
|
||||
}
|
||||
|
||||
export type Command =
|
||||
"frac" | "sub" | "sup" | "paren" | "×" | "÷" | "%" | "≤" | "≥" | "≠" | "if" | "round";
|
||||
|
||||
let active: MathEditor | null = null;
|
||||
// 누르는 중 편집기를 떠나면 — 놓은 뒤(그 누름이 먼저 돌고) 나옴
|
||||
let pressing = false;
|
||||
let afterPress: (() => void) | null = null;
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("pointerdown", () => (pressing = true), true);
|
||||
document.addEventListener(
|
||||
"pointerup",
|
||||
() => {
|
||||
pressing = false;
|
||||
const run = afterPress;
|
||||
afterPress = null;
|
||||
if (run) window.setTimeout(run, 0);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
/** 지금 열린 편집기(도구 줄이 부름) */
|
||||
export const activeMath = (): MathEditor | null => active;
|
||||
|
||||
const LATIN = /^[A-Za-z]$/;
|
||||
const NUMERIC = /^[\d.,]$/;
|
||||
|
||||
/** 줄(`host`)을 편집기로 */
|
||||
export function openMath(host: HTMLElement, h: MathHooks): MathEditor {
|
||||
active?.close();
|
||||
host.replaceChildren();
|
||||
host.dataset.editing = "1";
|
||||
host.classList.add("fdoc-math");
|
||||
const root: Item[] = [];
|
||||
fromLinear(h.text, root);
|
||||
let caret: Caret = h.at === "start" ? { list: root, i: 0 } : { list: root, i: root.length };
|
||||
let all = false;
|
||||
let closed = false;
|
||||
let composing = "";
|
||||
let used = 0; // 숨은 칸에서 이미 받은 글자 수
|
||||
const view = el("span", { className: "fdoc-math__view" });
|
||||
const sink = el("textarea", {
|
||||
className: "fdoc-math__sink",
|
||||
attrs: { "aria-label": ft("Sym_Head"), spellcheck: "false", autocomplete: "off", rows: "1" },
|
||||
});
|
||||
host.append(view, sink);
|
||||
const boxes = new Map<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;
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_math_model.ts
|
||||
* 수식 영역 구조 편집 꼴(PLAN 803-2a · 요구 667-4 · 6) — 발표 자료 수식처럼 선형 입력이 바로 조판으로 · 화면 · DOM 없음
|
||||
* 한 줄 = 항목 목록 — 글자 · 분수(위 · 아래) · 괄호(안) · 아래첨자 · 위첨자(기호 이름 조각)
|
||||
* 치기(`typeChar`) — `/` = 앞 피연산자를 분자로 · `_` · `^` = 첨자 칸 · `(` = 괄호(닫힘 자동) · `)` = 괄호 밖으로
|
||||
* · `*` → × · `-` → − · `<=` → ≤ · 첨자 칸에서 연산자 · 빈칸 = 첨자 밖으로
|
||||
* 선형 글 ↔ 항목 — `fromLinear`(공용 `serializeEq` 글 꼴 `(위) / (아래)`) · `toLinear`(식 글 · 첨자 `_{…}`)
|
||||
* 커서 걸음 — 좌우(구조 안팎) · 위아래(분수 위 ↔ 아래) · 지움(구조 풀기)
|
||||
* ========================================================================== */
|
||||
|
||||
export type Item =
|
||||
| { t: "ch"; c: string }
|
||||
| { t: "frac"; num: Item[]; den: Item[] }
|
||||
| { t: "paren"; body: Item[] }
|
||||
| { t: "sub"; body: Item[] }
|
||||
| { t: "sup"; body: Item[] };
|
||||
|
||||
export interface Caret {
|
||||
list: Item[];
|
||||
i: number;
|
||||
}
|
||||
|
||||
interface Owner {
|
||||
item: Item;
|
||||
/** 이 항목이 든 목록 · 자리 */
|
||||
list: Item[];
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** 연산자 — 피연산자 경계 · 앞뒤 띄움 */
|
||||
const OPS = new Set(["+", "−", "×", "÷", "=", "==", "<", ">", "≤", "≥", "≠", ","]);
|
||||
const NAME = /^[\p{L}\p{N}′.]$/u;
|
||||
const RUN = /^[\p{L}\p{N}′']+$/u;
|
||||
const MERGE: Record<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 it.c === "," ? ", " : OPS.has(it.c) ? ` ${it.c} ` : it.c;
|
||||
if (it.t === "frac") return `(${go(it.num)}) / (${go(it.den)})`;
|
||||
if (it.t === "paren") return `(${go(it.body)})`;
|
||||
const body = go(it.body).trim();
|
||||
if (!body) return "";
|
||||
return `${it.t === "sub" ? "_" : "^"}${RUN.test(body) ? body : `{${body}}`}`;
|
||||
})
|
||||
.join("");
|
||||
return go(items).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// ── 커서 걸음 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function right(root: Item[], c: Caret): Caret {
|
||||
if (c.i < c.list.length) {
|
||||
const s = slotsOf(c.list[c.i]);
|
||||
return s.length ? { list: s[0], i: 0 } : { list: c.list, i: c.i + 1 };
|
||||
}
|
||||
const o = ownerOf(root, c.list);
|
||||
if (!o) return c;
|
||||
const s = slotsOf(o.item);
|
||||
const k = s.indexOf(c.list);
|
||||
return k < s.length - 1 ? { list: s[k + 1], i: 0 } : { list: o.list, i: o.at + 1 };
|
||||
}
|
||||
|
||||
export function left(root: Item[], c: Caret): Caret {
|
||||
if (c.i > 0) {
|
||||
const s = slotsOf(c.list[c.i - 1]);
|
||||
const last = s[s.length - 1];
|
||||
return last ? { list: last, i: last.length } : { list: c.list, i: c.i - 1 };
|
||||
}
|
||||
const o = ownerOf(root, c.list);
|
||||
if (!o) return c;
|
||||
const s = slotsOf(o.item);
|
||||
const k = s.indexOf(c.list);
|
||||
return k > 0 ? { list: s[k - 1], i: s[k - 1].length } : { list: o.list, i: o.at };
|
||||
}
|
||||
|
||||
/** 분수 위 ↔ 아래(분수 밖이면 null — 줄을 옮김) */
|
||||
export function vertical(root: Item[], c: Caret, up: boolean): Caret | null {
|
||||
for (let at = c.list, o = ownerOf(root, at); o; o = ownerOf(root, (at = o.list))) {
|
||||
if (o.item.t !== "frac") continue;
|
||||
const from = at === o.item.num ? "num" : "den";
|
||||
if ((up && from === "num") || (!up && from === "den")) continue;
|
||||
const to = up ? o.item.num : o.item.den;
|
||||
return { list: to, i: Math.min(c.i, to.length) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 지움(뒤로) — 글자 지움 · 구조 앞이면 구조 안 끝으로 · 칸 맨 앞이면 구조를 풂 · 빈 줄 맨 앞 = null */
|
||||
export function backspace(root: Item[], c: Caret): Caret | null {
|
||||
if (c.i > 0) {
|
||||
const prev = c.list[c.i - 1];
|
||||
if (prev.t === "ch") {
|
||||
c.list.splice(c.i - 1, 1);
|
||||
return { list: c.list, i: c.i - 1 };
|
||||
}
|
||||
return left(root, c);
|
||||
}
|
||||
const o = ownerOf(root, c.list);
|
||||
if (!o) return root.length ? c : null;
|
||||
const parts = slotsOf(o.item);
|
||||
const flat: Item[] =
|
||||
o.item.t === "frac" && o.item.den.length && o.item.num.length
|
||||
? [...o.item.num, ch("÷"), ...o.item.den]
|
||||
: parts.flat();
|
||||
o.list.splice(o.at, 1, ...flat);
|
||||
const before = parts.indexOf(c.list) > 0 ? parts[0].length : 0;
|
||||
return { list: o.list, i: o.at + before };
|
||||
}
|
||||
|
||||
/** 지움(앞으로) — 글자 지움 · 구조면 안으로 */
|
||||
export function del(root: Item[], c: Caret): Caret {
|
||||
if (c.i >= c.list.length) return c;
|
||||
if (c.list[c.i].t === "ch") {
|
||||
c.list.splice(c.i, 1);
|
||||
return c;
|
||||
}
|
||||
return right(root, c);
|
||||
}
|
||||
@@ -1,267 +1,25 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_render.ts
|
||||
* 수식 문서 그림(PLAN 802-3 · 요구 660~664) — 식 나무 → 조판 수식(분수 · 첨자 · 괄호) · 화면 공용(M01 · M02)
|
||||
* 기호 식(side "sym") = 기호만(한글 이름 · 표 칸 글 · DB 키 없음) · 값 식(side "val") = 기호 자리에 값 + 단위
|
||||
* 만약 = 탄 갈래만(값을 알 때) · 반올림 · 올림 · 버림 자리 감싸개 숨김(662-4) — 숨긴 글은 `data-pre` · `data-post`
|
||||
* 고침 글 꼴 = 공용 `serializeEq`(원자 `data-tok` · 분수 · 괄호 · 단위 뺌) — 보이는 그림이 곧 고침 자리
|
||||
* evalNode = 그림용 값(탄 갈래 · 값 식) — 셈 정본은 계산기(`common_util_formula_doc_calc.ts`)
|
||||
* reach = 지금 탄 갈래로 닿는 기호(정의 영역에 보일 것)
|
||||
* 수식 문서 닿는 기호(PLAN 802-3 · 804-6) — 줄 쌓기 산출근거(`line_stack_out.ts`)가 씀
|
||||
* reach = 지금 탄 갈래로 닿는 기호(만약 안 탄 갈래 · 숨김 자리 인자 빼고)
|
||||
* 옛 수식 문서 탭의 조판 그림(renderNode · 값 식 · 수 바꿈)은 804-6 에 걷음
|
||||
* ========================================================================== */
|
||||
|
||||
import { eqText, symbolOf } from "@ui/ui_template_equation";
|
||||
import {
|
||||
linkNames,
|
||||
splitId,
|
||||
textOf,
|
||||
varId,
|
||||
type DocVar,
|
||||
type FormulaDoc,
|
||||
type Node,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||||
import {
|
||||
leafNum,
|
||||
offLine,
|
||||
takenOf,
|
||||
tryParse,
|
||||
type Values,
|
||||
} from "../common_util/common_util_formula_doc_view";
|
||||
|
||||
export {
|
||||
evalNode,
|
||||
leafNum,
|
||||
lineMap,
|
||||
offLine,
|
||||
offZero,
|
||||
takenOf,
|
||||
tryParse,
|
||||
type Values,
|
||||
} from "../common_util/common_util_formula_doc_view";
|
||||
const HIDE = new Set(["반올림", "올림", "버림"]);
|
||||
const SHOW: Record<string, string> = {
|
||||
"*": "×",
|
||||
"/": "÷",
|
||||
"-": "−",
|
||||
"<=": "≤",
|
||||
">=": "≥",
|
||||
"!=": "≠",
|
||||
};
|
||||
const PREC: Record<string, number> = {
|
||||
또는: 1,
|
||||
그리고: 2,
|
||||
"<": 3,
|
||||
"<=": 3,
|
||||
">": 3,
|
||||
">=": 3,
|
||||
"==": 3,
|
||||
"!=": 3,
|
||||
"+": 4,
|
||||
"-": 4,
|
||||
"*": 5,
|
||||
"/": 5,
|
||||
};
|
||||
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
|
||||
|
||||
export interface Ctx {
|
||||
side: "sym" | "val";
|
||||
vals: Values;
|
||||
vars: Map<string, DocVar>;
|
||||
/** ÷ 를 분수 대신 한 줄로(금액 줄 · 경쟁사 꼴) */
|
||||
inline?: boolean;
|
||||
/** 묶음 값의 비목(금액 묶음 줄 노 · 재 · 경 줄) */
|
||||
cost?: string;
|
||||
/** 수 마디 → 차례(값 식 숫자 고침) */
|
||||
lits?: Map<Node, number>;
|
||||
}
|
||||
|
||||
const span = (cls: string, text?: string): HTMLSpanElement => {
|
||||
const s = document.createElement("span");
|
||||
s.className = cls;
|
||||
if (text !== undefined) s.textContent = text;
|
||||
return s;
|
||||
};
|
||||
|
||||
/** 기호 글(`Q_시공^표`) → 기호 그림(첨자) */
|
||||
export function symNode(id: string): HTMLElement {
|
||||
const [기호, 아래, 위] = splitId(id);
|
||||
return symbolOf({ 기호, 아래, 위, 열쇠: id });
|
||||
}
|
||||
|
||||
/** 값 글 + 단위 */
|
||||
export function valueNode(value: unknown, unit = ""): HTMLElement {
|
||||
const box = span("fdoc__val");
|
||||
box.append(span("ui-eq__num", eqText(value)));
|
||||
if (unit) box.append(span("ui-eq__unit", ` ${unit}`));
|
||||
return box;
|
||||
}
|
||||
|
||||
const paren = (inner: HTMLElement): HTMLElement => {
|
||||
const p = span("ui-eq__paren");
|
||||
p.append(span("ui-eq__inner"));
|
||||
p.firstElementChild!.append(inner);
|
||||
return p;
|
||||
};
|
||||
|
||||
const op = (o: string): HTMLElement => span("ui-eq__op", SHOW[o] ?? o);
|
||||
|
||||
/** 감쌈 — 그림엔 안 보이는 앞 · 뒤 글(고침 때 그대로 되돌림) */
|
||||
const hidden = (pre: string, post: string, inner: HTMLElement): HTMLElement => {
|
||||
const w = span("fdoc__wrap");
|
||||
w.dataset.pre = pre;
|
||||
w.dataset.post = post;
|
||||
w.append(inner);
|
||||
return w;
|
||||
};
|
||||
|
||||
function varNode(id: string, c: Ctx): HTMLElement {
|
||||
if (c.side === "sym") {
|
||||
const a = span("ui-eq__var fdoc__atom");
|
||||
a.dataset.tok = id;
|
||||
a.dataset.var = id;
|
||||
a.append(symNode(id));
|
||||
return a;
|
||||
}
|
||||
let v = c.vals[id];
|
||||
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
|
||||
const a = span("ui-eq__var fdoc__atom is-val");
|
||||
a.dataset.tok = id;
|
||||
a.dataset.var = id;
|
||||
if (v === null || v === undefined || typeof v === "object") {
|
||||
a.classList.add("is-missing"); // 값 없음 · 정의 필요 — 기호 그대로(빨강)
|
||||
a.append(symNode(id));
|
||||
} else a.append(valueNode(v, c.vars.get(id)?.단위 ?? ""));
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 나무 → 조판 수식 */
|
||||
export function renderNode(n: Node, c: Ctx): HTMLElement {
|
||||
const go = (x: Node): HTMLElement => renderNode(x, c);
|
||||
switch (n[0]) {
|
||||
case "num": {
|
||||
const s = span("ui-eq__num fdoc__lit", eqText(n[1]));
|
||||
const at = c.lits?.get(n);
|
||||
if (at !== undefined) s.dataset.lit = String(at);
|
||||
return s;
|
||||
}
|
||||
case "str": {
|
||||
const s = span("ui-eq__str fdoc__atom", n[1]);
|
||||
s.dataset.tok = `'${n[1]}'`;
|
||||
return s;
|
||||
}
|
||||
case "var":
|
||||
return varNode(n[1], c);
|
||||
case "field": {
|
||||
if (c.side === "val" && n[1][0] === "var") {
|
||||
const v = c.vals[n[1][1]];
|
||||
const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
|
||||
if (isNum(got)) {
|
||||
const a = span("ui-eq__var fdoc__atom is-val");
|
||||
a.dataset.tok = textOf(n);
|
||||
a.dataset.var = n[1][1];
|
||||
a.append(valueNode(got));
|
||||
return a;
|
||||
}
|
||||
}
|
||||
const a = span("ui-eq__var fdoc__atom");
|
||||
a.dataset.tok = textOf(n);
|
||||
if (n[1][0] === "var") a.dataset.var = n[1][1];
|
||||
const base = n[1][0] === "var" ? symNode(n[1][1]) : span("", textOf(n[1]));
|
||||
a.append(base, span("ui-eq__script", `.${n[2]}`));
|
||||
return a;
|
||||
}
|
||||
case "neg": {
|
||||
const s = span("ui-eq__body");
|
||||
s.append(op("-"), n[1][0] === "bin" ? paren(go(n[1])) : go(n[1]));
|
||||
return s;
|
||||
}
|
||||
case "pct": {
|
||||
const s = span("ui-eq__body");
|
||||
s.append(prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1]), span("ui-eq__op", "%"));
|
||||
return s;
|
||||
}
|
||||
case "call": {
|
||||
const [, name, items] = n;
|
||||
if (name === "만약") {
|
||||
const pick = takenOf(n, c.vals);
|
||||
if (pick) {
|
||||
const [t, a, b] = items.map(textOf);
|
||||
return pick === 1
|
||||
? hidden(`만약(${t}, `, `, ${b})`, go(items[1]))
|
||||
: hidden(`만약(${t}, ${a}, `, ")", go(items[2]));
|
||||
}
|
||||
}
|
||||
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) {
|
||||
const post = items.length === 2 ? `, ${textOf(items[1])})` : ")";
|
||||
return hidden(`${name}(`, post, go(items[0]));
|
||||
}
|
||||
const s = span("ui-eq__body");
|
||||
const inner = span("ui-eq__body");
|
||||
items.forEach((x, i) => {
|
||||
if (i) inner.append(span("ui-eq__op", ","));
|
||||
inner.append(go(x));
|
||||
});
|
||||
s.append(span("ui-eq__fn", name), paren(inner));
|
||||
return s;
|
||||
}
|
||||
case "bin": {
|
||||
const [, o, a, b] = n;
|
||||
// 선택으로 꺼진 항(만약이 0 · 1 을 탐) = 안 보임(663-1) · 고침 글은 그대로
|
||||
const na = leafNum(a, c.vals);
|
||||
const nb = leafNum(b, c.vals);
|
||||
if ((o === "+" || o === "-") && nb === 0) return hidden("(", `) ${o} ${textOf(b)}`, go(a));
|
||||
if ((o === "*" || o === "/") && nb === 1) return hidden("(", `) ${o} ${textOf(b)}`, go(a));
|
||||
if ((o === "+" && na === 0) || (o === "*" && na === 1))
|
||||
return hidden(`${textOf(a)} ${o} (`, ")", go(b));
|
||||
if (o === "/" && !c.inline) {
|
||||
const f = span("ui-eq__frac");
|
||||
const top = span("ui-eq__top");
|
||||
const low = span("ui-eq__bottom");
|
||||
top.append(go(a));
|
||||
low.append(go(b));
|
||||
f.append(top, low);
|
||||
return f;
|
||||
}
|
||||
const mine = PREC[o];
|
||||
const s = span("ui-eq__body");
|
||||
const left = go(a);
|
||||
const right = go(b);
|
||||
s.append(
|
||||
prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(left) : left,
|
||||
op(o),
|
||||
prec(b) <= mine ? paren(right) : right,
|
||||
);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 수 마디 차례(나무 걸음 차례) */
|
||||
export function litsOf(n: Node): Map<Node, number> {
|
||||
const out = new Map<Node, number>();
|
||||
const go = (x: Node): void => {
|
||||
if (x[0] === "num") out.set(x, out.size);
|
||||
if (x[0] === "bin") {
|
||||
go(x[2]);
|
||||
go(x[3]);
|
||||
} else if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") go(x[1]);
|
||||
else if (x[0] === "call") x[2].forEach(go);
|
||||
};
|
||||
go(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 차례 번째 수를 바꾼 식 글 */
|
||||
export function swapLit(text: string, at: number, now: number): string {
|
||||
const n = tryParse(text);
|
||||
if (!n) return text;
|
||||
const lits = litsOf(n);
|
||||
for (const [node, i] of lits) if (i === at) node[1] = now;
|
||||
for (const [node] of lits) (node as [string, number, string])[2] = String(node[1]);
|
||||
return textOf(n);
|
||||
}
|
||||
|
||||
/** 탄 갈래로 부르는 이름(만약 안 탄 갈래 · 숨김 자리 인자 빼고) */
|
||||
export function namesTaken(n: Node, vals: Values, out: string[] = []): string[] {
|
||||
function namesTaken(n: Node, vals: Values, out: string[] = []): string[] {
|
||||
const add = (x: string): void => {
|
||||
if (!out.includes(x)) out.push(x);
|
||||
};
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_rows.ts
|
||||
* 수식 영역 줄 편집 흐름(PLAN 803-2a · 요구 667-4 · 6) — 어느 줄을 편집 중인지 · 줄 사이 옮김 · 새 줄 · 문서에 넣기
|
||||
* 줄 누름 = 편집 중 글을 넣고 그 줄을 엶 · Enter = 아래 새 줄(금액 줄 뒤면 같은 비목 「노무비 」로 시작) ·
|
||||
* ↑ ↓ = 옆 줄(가로 자리 그대로) · 빈 줄 맨 앞 지움 = 줄 걷고 윗줄 끝 · Esc · 밖 누름 = 나옴 · 빈 줄은 나올 때 걷음
|
||||
* 글 → 문서 = `editRow`(식 줄 · 금액 줄 「노무비 형틀목공 = w × Q」 · 갈래가 바뀌면 옮김)
|
||||
* 그림 · 셈은 부른 쪽(`formula_doc_screen.ts` — `change` · `draw`) · 편집기는 `formula_doc_math.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FormulaDoc, MoneyLine } from "../common_util/common_util_formula_doc";
|
||||
import { editRow, splitLine, sweep } from "./formula_doc_edit";
|
||||
import type { RowRef } from "./formula_doc_lines";
|
||||
import { openMath, type Leave, type MathEditor, type MathHooks } from "./formula_doc_math";
|
||||
import { tryParse } from "./formula_doc_render";
|
||||
import { ft } from "./formula_doc_text";
|
||||
|
||||
interface Editing {
|
||||
ref: RowRef;
|
||||
at?: MathHooks["at"];
|
||||
/** 처음 글(새 금액 줄 「노무비 」) */
|
||||
seed?: string;
|
||||
}
|
||||
interface Current {
|
||||
ref: RowRef;
|
||||
first: string;
|
||||
ed: MathEditor | null;
|
||||
}
|
||||
|
||||
export interface RowsHost {
|
||||
doc: () => FormulaDoc | null;
|
||||
/** 문서 고침 → 캐시 · 셈 · 그림 */
|
||||
change: (fn: (d: FormulaDoc) => void) => void;
|
||||
/** 고침 없이 캐시 · 셈(그림 없음) */
|
||||
settle: () => void;
|
||||
draw: () => void;
|
||||
root: HTMLElement;
|
||||
}
|
||||
|
||||
export function createRows(host: RowsHost) {
|
||||
let editing: Editing | null = null;
|
||||
let current: Current | null = null;
|
||||
let order: RowRef[] = [];
|
||||
let live: { ref: RowRef; text: string; first: string } | null = null; // 다시 그림 사이 편집 중 글
|
||||
|
||||
const doc = (): FormulaDoc | null => host.doc();
|
||||
const rowObj = (r: RowRef): object | undefined =>
|
||||
(r.kind === "line" ? doc()?.식 : doc()?.금액)?.[r.i];
|
||||
const refOf = (o: object | null | undefined): RowRef | null => {
|
||||
const a = (doc()?.식 ?? []).indexOf(o as never);
|
||||
if (a >= 0) return { kind: "line", i: a };
|
||||
const b = (doc()?.금액 ?? []).indexOf(o as never);
|
||||
return b >= 0 ? { kind: "money", i: b } : null;
|
||||
};
|
||||
/** 편집 중 글을 문서에 — 돌려줌 = 그 줄 객체 */
|
||||
const commit = (d: FormulaDoc, c: Current, text: string): object | null =>
|
||||
text === c.first ? (rowObj(c.ref) ?? null) : editRow(d, c.ref.kind, c.ref.i, text);
|
||||
/** 새 줄 — `after` 뒤(식 줄 = 식 줄 · 금액 줄 = 같은 비목 · 묶음) · null = 영역 맨 끝 */
|
||||
const newRow = (d: FormulaDoc, after: object | null): { obj: object; seed: string } => {
|
||||
const lines = (d.식 ??= []);
|
||||
const money = (d.금액 ??= []);
|
||||
const li = after ? lines.indexOf(after as never) : -1;
|
||||
const mi = after ? money.indexOf(after as MoneyLine) : -1;
|
||||
const base = mi >= 0 ? money[mi] : li < 0 ? money[money.length - 1] : undefined;
|
||||
if (!base) {
|
||||
const o = { 기호: "", 식: "" };
|
||||
lines.splice(li >= 0 ? li + 1 : lines.length, 0, o);
|
||||
return { obj: o, seed: "" };
|
||||
}
|
||||
const kind = base.비목 ?? "노무비";
|
||||
const o: MoneyLine = {
|
||||
이름: "",
|
||||
비목: kind,
|
||||
단가: "",
|
||||
...(base.묶음 ? { 묶음: base.묶음 } : {}),
|
||||
};
|
||||
money.splice(mi >= 0 ? mi + 1 : money.length, 0, o);
|
||||
return { obj: o, seed: `${kind} ` };
|
||||
};
|
||||
const target = (obj: object | null, at?: MathHooks["at"], seed?: string): void => {
|
||||
const ref = refOf(obj);
|
||||
editing = ref ? { ref, at, ...(seed ? { seed } : {}) } : null;
|
||||
};
|
||||
const take = (): Current | null => {
|
||||
const c = current;
|
||||
current = live = null;
|
||||
c?.ed?.close();
|
||||
return c;
|
||||
};
|
||||
|
||||
/** 줄 누름 — 편집 중 글을 넣고 그 줄(또는 맨 끝 새 줄)을 엶 */
|
||||
const editAt = (ref: RowRef | "end", at?: { x: number; y: number }): void => {
|
||||
const c = take();
|
||||
const want = ref === "end" ? null : (rowObj(ref) ?? null);
|
||||
host.change((d) => {
|
||||
if (c) commit(d, c, c.ed?.text() ?? c.first);
|
||||
const made = ref === "end" ? newRow(d, null) : null;
|
||||
sweep(d, made?.obj ?? want);
|
||||
target(made?.obj ?? want, at ?? "end", made?.seed);
|
||||
});
|
||||
};
|
||||
|
||||
/** 편집기에서 나옴 — Enter = 새 줄 · ↑ ↓ = 옆 줄 · 지움 = 빈 줄 걷고 윗줄 끝 */
|
||||
const finish = (c: Current, text: string, how: Leave, x?: number): void => {
|
||||
if (current === c) current = null;
|
||||
live = null;
|
||||
const at = order.findIndex((r) => r.kind === c.ref.kind && r.i === c.ref.i);
|
||||
const near =
|
||||
how === "down" ? order[at + 1] : how === "up" || how === "back" ? order[at - 1] : undefined;
|
||||
const nearObj = near ? (rowObj(near) ?? null) : null;
|
||||
const d = doc();
|
||||
if (how === "exit" && text === c.first && d) {
|
||||
editing = null;
|
||||
const before = JSON.stringify(d);
|
||||
sweep(d);
|
||||
if (JSON.stringify(d) !== before) host.settle();
|
||||
// 값 식 숫자 등 다른 자리를 고치는 중이면 그대로(다음 그림에서 정리)
|
||||
if (!host.root.querySelector("[data-editing]:not(.fdoc-math)")) host.draw();
|
||||
return;
|
||||
}
|
||||
host.change((dd) => {
|
||||
const mine = commit(dd, c, text);
|
||||
const made = how === "enter" ? newRow(dd, mine) : null;
|
||||
sweep(dd, made?.obj ?? nearObj);
|
||||
const pos: MathHooks["at"] =
|
||||
how === "up" || how === "down" ? { x: x ?? 0 } : how === "back" ? "end" : "start";
|
||||
target(made?.obj ?? nearObj, pos, made?.seed);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
editing: (): RowRef | null => editing?.ref ?? null,
|
||||
editingMoney: (): boolean => editing?.ref.kind === "money",
|
||||
setOrder: (o: RowRef[]): void => void (order = o),
|
||||
editAt,
|
||||
/** 다시 그리기 앞 — 편집 중 글을 기억(같은 줄을 다시 열 때 이어 씀) */
|
||||
beforeDraw: (): void => {
|
||||
const c = current;
|
||||
if (!c?.ed) return;
|
||||
live = { ref: c.ref, text: c.ed.text(), first: c.first };
|
||||
c.ed.close();
|
||||
current = null;
|
||||
},
|
||||
/** 편집 중 글을 문서에 넣고 닫음(그림 없음 · 저장 · 나가기 앞) */
|
||||
flush: (): void => {
|
||||
const c = take();
|
||||
editing = null;
|
||||
const d = doc();
|
||||
if (!c || !d) return;
|
||||
commit(d, c, c.ed?.text() ?? c.first);
|
||||
sweep(d);
|
||||
host.settle();
|
||||
},
|
||||
/** 편집 버림(문서 바뀜 · 고침 버림) */
|
||||
reset: (): void => {
|
||||
take();
|
||||
editing = null;
|
||||
},
|
||||
mount: (el: HTMLElement, ref: RowRef, text: string): void => {
|
||||
const e = editing;
|
||||
if (!e) return;
|
||||
const again = live && live.ref.kind === ref.kind && live.ref.i === ref.i ? live : null;
|
||||
live = null;
|
||||
const first = again?.first ?? e.seed ?? text;
|
||||
const c: Current = { ref, first, ed: null };
|
||||
current = c;
|
||||
queueMicrotask(() => {
|
||||
if (current !== c || !el.isConnected) return;
|
||||
c.ed = openMath(el, {
|
||||
text: again?.text ?? first,
|
||||
at: again ? "end" : (e.at ?? "end"),
|
||||
onInput: (t) => {
|
||||
const body = splitLine(t)[1];
|
||||
el.toggleAttribute("data-bad", !!body.trim() && !tryParse(body));
|
||||
},
|
||||
onLeave: (t, how, x) => finish(c, t, how, x),
|
||||
});
|
||||
});
|
||||
},
|
||||
/** 편집 중 금액 줄부터 그 묶음 끝까지 새 묶음(이름은 머리를 눌러 고침) */
|
||||
splitGroup: (): void => {
|
||||
const c = current;
|
||||
if (!c || c.ref.kind !== "money") return;
|
||||
take();
|
||||
host.change((d) => {
|
||||
const mine = commit(d, c, c.ed?.text() ?? c.first) as MoneyLine | null;
|
||||
const money = d.금액 ?? [];
|
||||
const at = mine ? money.indexOf(mine) : -1;
|
||||
if (at < 0) return void (editing = null);
|
||||
const old = mine!.묶음 ?? "";
|
||||
const taken = new Set(money.map((l) => l.묶음 ?? ""));
|
||||
let name = ft("New_Group");
|
||||
for (let n = 2; taken.has(name); n++) name = `${ft("New_Group")} ${n}`;
|
||||
for (let k = at; k < money.length && (money[k].묶음 ?? "") === old; k++)
|
||||
money[k].묶음 = name;
|
||||
target(mine, "end");
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,667 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_screen.ts
|
||||
* 단가산출 기초 수식 문서 화면(PLAN 802-3 · 803-2a · 요구 660~667 · 요구서_664 3 장) — 공용(M01 · M02 = 길 앞머리만 바꿈)
|
||||
* 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · [고침 버림] · [수식 저장](관리자) — 설명 글 없음
|
||||
* 가운데 = 세 영역 가로 — 기호 식(하나의 수식 영역 · 줄바꿈) | 값 식(같은 높이) | 기호 정의(`formula_doc_defs.ts`)
|
||||
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 단가산출 상세 탭 부품 · `formula_doc_foot.ts`)
|
||||
* 기호 식 줄 누름 = 그 자리 편집기(`formula_doc_math.ts`) · 편집 중엔 영역 위 수식 도구 줄 · 빈 문서 = 세 영역 빈 화면
|
||||
* 빈 문서에서 시작 — 영역을 눌러 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름
|
||||
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 연결 값은 서버 기록 캐시(`common_util_formula_doc_cache.ts`)
|
||||
* · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈
|
||||
* 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀)
|
||||
* 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」
|
||||
* M02 사본(`opt.copy` · 802-4b-2) — 길 앞머리 = SK 마다(`/m02/layers/{층}/sangeun/{키}/doc`) · 옵션 = 문서 값(SK 고른 값 · 저장) ·
|
||||
* [단가 갱신] · [재생성] · 「M01 새 판 있음」(`formula_doc_copy.ts`) · 단가산출 연결 끊기 · 다시 잇기(`formula_doc_sub.ts`) ·
|
||||
* 저장 몸 {문서, 끊음, 판} · `admin` = 층 쓰기 권한(없으면 읽기만 · 옵션도 잠금)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { editNumber } from "@ui/ui_template_equation_edit";
|
||||
import {
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import {
|
||||
DocError,
|
||||
blankDoc,
|
||||
rename,
|
||||
varId,
|
||||
type FormulaDoc,
|
||||
type Link,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import {
|
||||
askJob,
|
||||
copyBadges,
|
||||
copyTools,
|
||||
type CopyJob,
|
||||
type CopyMark,
|
||||
type CopyOptions,
|
||||
type JobReply,
|
||||
} from "./formula_doc_copy";
|
||||
import { buildDefs, type Pick } from "./formula_doc_defs";
|
||||
import { editLit, ensureVar, splitLine, sweep } from "./formula_doc_edit";
|
||||
import { docFoot } from "./formula_doc_foot";
|
||||
import { buildLines, offLines } from "./formula_doc_lines";
|
||||
import { mathToolbar } from "./formula_doc_math";
|
||||
import { buildOptions } from "./formula_doc_options";
|
||||
import { reach } from "./formula_doc_render";
|
||||
import { createRows } from "./formula_doc_rows";
|
||||
import { subBlock } from "./formula_doc_sub";
|
||||
import { ft } from "./formula_doc_text";
|
||||
|
||||
export { splitLine };
|
||||
import "./formula_doc.css";
|
||||
|
||||
export interface DocScreenOptions {
|
||||
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links · GET /table */
|
||||
base: string;
|
||||
admin: boolean;
|
||||
/** sessionStorage 열쇠 */
|
||||
cache: string;
|
||||
/** M02 사본(SK) — 길 앞머리 · 판 · 끊음 · 할일 */
|
||||
copy?: CopyOptions;
|
||||
}
|
||||
|
||||
type Docs = Record<string, FormulaDoc>;
|
||||
|
||||
interface Loaded {
|
||||
문서: FormulaDoc;
|
||||
셈: DocAnswer;
|
||||
연결: DocRecord;
|
||||
새?: boolean;
|
||||
부름?: Docs;
|
||||
끊음?: Docs;
|
||||
사본?: CopyMark;
|
||||
판?: string;
|
||||
}
|
||||
|
||||
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 grid = el("div", { className: "fdoc__grid" });
|
||||
const side = el("div", { className: "fdoc__side" });
|
||||
const foot = el("div", { className: "fdoc__footslot" });
|
||||
const root = el("div", {
|
||||
className: "fdoc ui-eq",
|
||||
attrs: { "data-formula-doc": "" },
|
||||
children: [head, el("div", { className: "fdoc__areas", children: [grid, side] }), foot],
|
||||
});
|
||||
let key = "";
|
||||
let saved = "";
|
||||
let doc: FormulaDoc | null = null;
|
||||
let answer: DocAnswer | null = null;
|
||||
let cache = createDocCache();
|
||||
let given: Record<string, unknown> = {};
|
||||
let openDef: string | null = null;
|
||||
let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드)
|
||||
let seq = 0;
|
||||
let asked = "";
|
||||
let ctl: AbortController | null = null;
|
||||
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 = "{}";
|
||||
let calls: Docs = {}; // 사본 — 부름 문서(다시 잇기 = 이 문서로)
|
||||
let mark: CopyMark | undefined;
|
||||
let version = "";
|
||||
|
||||
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
|
||||
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal,
|
||||
});
|
||||
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
|
||||
if (!r.ok) {
|
||||
const why = data.detail;
|
||||
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
|
||||
status: r.status,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// ── 캐시(sessionStorage) ──
|
||||
const drafts = (): Record<string, FormulaDoc> => {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const dirty = (): boolean =>
|
||||
!!doc && (JSON.stringify(doc) !== saved || JSON.stringify(cut) !== savedCut);
|
||||
const cutKey = (): string => `${key}#끊음`;
|
||||
const keep = (): void => {
|
||||
const all = drafts();
|
||||
if (dirty()) all[key] = doc!;
|
||||
else delete all[key];
|
||||
if (cp && dirty()) all[cutKey()] = cut as unknown as FormulaDoc;
|
||||
else delete all[cutKey()];
|
||||
try {
|
||||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||||
} catch {
|
||||
/* 캐시가 막혀도 화면 안 고침은 남음 */
|
||||
}
|
||||
};
|
||||
|
||||
// ── 셈 ──
|
||||
const usable = (): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const v of doc?.변수 ?? []) {
|
||||
const k = v.열쇠;
|
||||
if (!k || !(k in given)) continue;
|
||||
const kind = v.연결?.갈래;
|
||||
if (kind === "입력") out[k] = given[k];
|
||||
if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k])))
|
||||
out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k]));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const recalc = (): void => {
|
||||
if (!doc) return;
|
||||
answer = calcDoc(doc, cache.links, usable());
|
||||
if (cache.takeMissing()) ask();
|
||||
};
|
||||
/** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함(같은 문서 · 입력은 한 번만) */
|
||||
const ask = (): void => {
|
||||
const snap = JSON.stringify([doc, usable()]);
|
||||
if (snap === asked) return;
|
||||
asked = snap;
|
||||
ctl?.abort();
|
||||
ctl = new AbortController();
|
||||
const mine = ++seq;
|
||||
void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>(
|
||||
"/preview",
|
||||
cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() },
|
||||
ctl.signal,
|
||||
)
|
||||
.then((got) => {
|
||||
if (mine !== seq) return;
|
||||
cache.merge(got.연결);
|
||||
calls = { ...calls, ...(got.부름 ?? {}) };
|
||||
cache.merge({ 문서: cut });
|
||||
recalc();
|
||||
draw();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const change = (fn: (d: FormulaDoc) => void): void => {
|
||||
if (!doc) return;
|
||||
fn(doc);
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
};
|
||||
|
||||
const onPickVar = (id: string, num?: HTMLElement): void => {
|
||||
const v = doc?.변수?.find((x) => varId(x) === id);
|
||||
if (opt.admin && num && v?.연결?.갈래 === "숫자") {
|
||||
editNumber(num, (t) => {
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
change((d) => (ensureVar(d, id).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
openDef = id;
|
||||
draw();
|
||||
grid.querySelector(`[data-def="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
const tableOf = (k: string): Promise<DocTable | null> => {
|
||||
if (!tables.has(k))
|
||||
tables.set(
|
||||
k,
|
||||
call<DocTable & { 끊김?: string }>(`/table?key=${encodeURIComponent(k)}`)
|
||||
.then((t) => {
|
||||
if (t.끊김) return null;
|
||||
cache.putTable(k, t);
|
||||
return t;
|
||||
})
|
||||
.catch(() => null),
|
||||
);
|
||||
return tables.get(k)!;
|
||||
};
|
||||
|
||||
const subDoc = (k: string): FormulaDoc | null => {
|
||||
try {
|
||||
return cache.links.doc(k) ?? null;
|
||||
} catch {
|
||||
cache.takeMissing();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ── 사본 — 단가산출 연결 끊기 · 다시 잇기 · 끊은 문서 고침 ──
|
||||
const changeCut = (k: string, fn: (d: FormulaDoc) => void): void => {
|
||||
if (!cut[k]) return;
|
||||
fn(cut[k]);
|
||||
cache.merge({ 문서: { [k]: cut[k] } });
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
};
|
||||
const onCut = (k: string): void => {
|
||||
const from = subDoc(k);
|
||||
if (!from) return;
|
||||
cut = { ...cut, [k]: clone(from) };
|
||||
changeCut(k, () => undefined);
|
||||
};
|
||||
const onJoin = async (k: string): Promise<void> => {
|
||||
if (!(await showConfirmDialog(ft("Ask_Relink"), ft("Relink")))) return;
|
||||
const { [k]: _gone, ...rest } = cut;
|
||||
cut = rest;
|
||||
if (calls[k]) cache.merge({ 문서: { [k]: calls[k] } });
|
||||
else asked = ""; // 부름 문서를 아직 모름 — 뒤에서 서버에 물음
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
};
|
||||
const sub = (id: string): HTMLElement | null =>
|
||||
subBlock(id, {
|
||||
doc,
|
||||
answer,
|
||||
cache,
|
||||
...(cp
|
||||
? {
|
||||
copy: {
|
||||
edit: opt.admin,
|
||||
has: (k: string) => k in calls || k in cut,
|
||||
cut: (k: string) => cut[k],
|
||||
onCut,
|
||||
onJoin: (k: string) => void onJoin(k),
|
||||
change: changeCut,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
// ── 그림 ──
|
||||
/** 옵션 가로 — M01 = 화면 값만(`given`) · 사본 = 문서 값(SK 고른 값 · 저장) */
|
||||
const optionBar = (): HTMLElement[] =>
|
||||
buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
|
||||
const at = doc?.변수?.indexOf(v) ?? -1; // 옵션 열쇠 ≠ 기호 — 그 정의 자리에 값
|
||||
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
|
||||
given[k] = x;
|
||||
recalc();
|
||||
draw();
|
||||
});
|
||||
|
||||
/** 위 도구 줄 — 결과 기호(금액 줄 없을 때) · 고침 표 · 사본 표 · [꺼진 줄] · [고침 버림] · [수식 저장] */
|
||||
const toolRow = (edit: boolean): HTMLElement => {
|
||||
const bits: HTMLElement[] = [];
|
||||
if (edit && doc && !(doc.금액 ?? []).length) {
|
||||
const f = createInputField({
|
||||
placeholder: ft("Result_Sym"),
|
||||
value: doc.결과 ?? "",
|
||||
compact: true,
|
||||
size: 24,
|
||||
});
|
||||
f.input.addEventListener("change", () =>
|
||||
change((d) => {
|
||||
const t = f.input.value.trim();
|
||||
if (t) d.결과 = t;
|
||||
else delete d.결과;
|
||||
}),
|
||||
);
|
||||
f.root.classList.add("fdoc__resultsym");
|
||||
bits.push(f.root);
|
||||
}
|
||||
if (dirty()) bits.push(createBadge(ft("Dirty"), "warning"));
|
||||
if (cp) bits.push(...copyBadges(mark, opt.admin));
|
||||
const off = doc && answer ? offLines(doc, answer).size : 0;
|
||||
const busy = dirty() || !!rows.editing();
|
||||
// 편집 중 누름 — 편집기 커서를 안 뺏음(저장 · 버림이 편집 중 글을 먼저 받음)
|
||||
const hold = (b: HTMLButtonElement): HTMLButtonElement => {
|
||||
b.addEventListener("mousedown", (ev) => ev.preventDefault());
|
||||
return b;
|
||||
};
|
||||
const tools = edit
|
||||
? [
|
||||
...(off
|
||||
? [
|
||||
createButton({
|
||||
label: `${showOff ? ft("Hide_Off") : ft("Show_Off")} ${off}`,
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
showOff = !showOff;
|
||||
draw();
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
|
||||
hold(
|
||||
createButton({
|
||||
label: ft("Discard"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
disabled: !busy,
|
||||
onClick: () => void discard(),
|
||||
}),
|
||||
),
|
||||
hold(
|
||||
createButton({
|
||||
label: ft("Save"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
disabled: !busy,
|
||||
onClick: () => void save(),
|
||||
}),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
return el("div", {
|
||||
className: "fdoc__sum",
|
||||
children: [
|
||||
el("div", { className: "fdoc__sumleft", children: bits }),
|
||||
el("div", { className: "fdoc__tools", children: tools }),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const draw = (): void => {
|
||||
rows.beforeDraw();
|
||||
if (!doc || !answer) {
|
||||
[head, grid, side, foot].forEach((x) => x.replaceChildren());
|
||||
return;
|
||||
}
|
||||
// M01 저장 전 새 로직(키 없음)도 고침 — [수식 저장] · [고침 버림]은 키가 생긴 뒤(페이지 [저장] → 새 키로 이어 감)
|
||||
// M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만
|
||||
const edit = opt.admin && (!!key || !cp);
|
||||
const vals = answer.값 ?? {};
|
||||
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
|
||||
const lineSet = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean));
|
||||
const shown = reach(doc, vals, showOff);
|
||||
const markMap = new Map<string, string>();
|
||||
for (const m of answer.표지 ?? []) {
|
||||
const at = m.기호 || (m as { 자리?: string }).자리;
|
||||
if (at && !markMap.has(at)) markMap.set(at, m.글);
|
||||
}
|
||||
const options = optionBar();
|
||||
head.replaceChildren(
|
||||
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
|
||||
toolRow(edit && !!key),
|
||||
);
|
||||
const { cells, order } = buildLines({
|
||||
admin: edit,
|
||||
showOff,
|
||||
doc,
|
||||
answer,
|
||||
vars,
|
||||
marks: markMap,
|
||||
editing: rows.editing(),
|
||||
mount: rows.mount,
|
||||
onEditAt: rows.editAt,
|
||||
onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)),
|
||||
onPickVar,
|
||||
onGroupName: (old, now) =>
|
||||
change((d) =>
|
||||
(d.금액 ?? []).forEach((l) => {
|
||||
if ((l.묶음 ?? "") !== old) return;
|
||||
if (now) l.묶음 = now;
|
||||
else delete l.묶음;
|
||||
}),
|
||||
),
|
||||
onRedraw: () => redraw(),
|
||||
});
|
||||
rows.setOrder(order);
|
||||
const defs = buildDefs({
|
||||
admin: edit,
|
||||
vars,
|
||||
names: cache.names,
|
||||
lines: lineSet,
|
||||
shown,
|
||||
unused: [...vars.keys()].filter((id) => !shown.includes(id)),
|
||||
need: new Set(
|
||||
shown.filter((id) => !lineSet.has(id) && (vars.get(id)?.연결?.갈래 ?? "식") === "식"),
|
||||
),
|
||||
open: openDef,
|
||||
onOpen: (id) => {
|
||||
openDef = id;
|
||||
draw();
|
||||
},
|
||||
setLink: (id, link: Link) =>
|
||||
change((d) => {
|
||||
const clean = Object.fromEntries(Object.entries(link).filter(([, x]) => x !== undefined));
|
||||
ensureVar(d, id).연결 = clean as Link;
|
||||
}),
|
||||
setVar: (id, patch) => change((d) => Object.assign(ensureVar(d, id), patch)),
|
||||
rename: (id, sign, low, up) => {
|
||||
const copy = clone(doc!);
|
||||
try {
|
||||
rename(copy, id, sign, low, up);
|
||||
} catch (e) {
|
||||
if (e instanceof DocError) return ft("Bad_Sign");
|
||||
throw e;
|
||||
}
|
||||
openDef = varId({ 기호: sign, 아래: low, 위: up });
|
||||
change((d) => Object.assign(d, copy));
|
||||
return null;
|
||||
},
|
||||
dropVar: (id) => {
|
||||
openDef = null;
|
||||
change((d) => {
|
||||
d.변수 = (d.변수 ?? []).filter((v) => varId(v) !== id);
|
||||
d.식 = (d.식 ?? []).filter((l) => l.기호 !== id);
|
||||
});
|
||||
},
|
||||
makeLine: (id) => {
|
||||
if (lineSet.has(id)) return;
|
||||
const i = (doc!.식 ?? []).length;
|
||||
change((d) => (d.식 ??= []).push({ 기호: id, 식: "" }));
|
||||
rows.editAt({ kind: "line", i });
|
||||
},
|
||||
ask: (kind, words, group) =>
|
||||
call<{ 후보: Pick[]; 더있음: boolean }>("/links", { 갈래: kind, 검색: words, 분류: group }),
|
||||
table: tableOf,
|
||||
subInputs: (k) =>
|
||||
(subDoc(k)?.변수 ?? [])
|
||||
.filter((v) => ["선택", "입력", "숫자"].includes(v.연결?.갈래 ?? "") && v.열쇠)
|
||||
.map((v) => v.열쇠!),
|
||||
sub,
|
||||
});
|
||||
const symHead = el("div", {
|
||||
className: "fdoc__colhead fdoc__symhead",
|
||||
children: [
|
||||
el("span", { text: ft("Sym_Head") }),
|
||||
...(rows.editing() ? [mathToolbar(rows.editingMoney() ? rows.splitGroup : undefined)] : []),
|
||||
],
|
||||
});
|
||||
grid.replaceChildren(
|
||||
symHead,
|
||||
el("div", { className: "fdoc__colhead", text: ft("Val_Head") }),
|
||||
...cells,
|
||||
);
|
||||
side.replaceChildren(defs);
|
||||
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
|
||||
};
|
||||
|
||||
/** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */
|
||||
const redraw = (): void => {
|
||||
if (!doc) return;
|
||||
const before = JSON.stringify(doc);
|
||||
sweep(doc);
|
||||
if (JSON.stringify(doc) !== before) {
|
||||
keep();
|
||||
recalc();
|
||||
}
|
||||
draw();
|
||||
};
|
||||
|
||||
const take = (got: Loaded): void => {
|
||||
cache.merge(got.연결);
|
||||
saved = JSON.stringify(got.문서);
|
||||
doc = clone(got.문서);
|
||||
if (!cp) return;
|
||||
calls = got.부름 ?? {};
|
||||
cut = clone(got.끊음 ?? {});
|
||||
savedCut = JSON.stringify(cut);
|
||||
mark = got.사본;
|
||||
version = got.판 ?? "";
|
||||
};
|
||||
|
||||
const body = (): Record<string, unknown> =>
|
||||
cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc };
|
||||
|
||||
/** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */
|
||||
const runJob = async (job: CopyJob): Promise<void> => {
|
||||
if (!doc || !cp) return;
|
||||
if (dirty()) return void showToast(ft("Dirty_First"), "warning");
|
||||
showLoadingOverlay();
|
||||
let got: JobReply;
|
||||
try {
|
||||
got = await call<JobReply>("/preview", { 할일: job });
|
||||
} catch (e) {
|
||||
hideLoadingOverlay();
|
||||
return void showToast(
|
||||
`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
hideLoadingOverlay();
|
||||
const names = new Map(Object.entries({ ...calls, ...cut }).map(([k, d]) => [k, d.이름 ?? ""]));
|
||||
if (await askJob(job, got, names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
|
||||
};
|
||||
|
||||
const save = async (send?: Record<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 }) => {
|
||||
// 사본 판 바뀜(페이지 [저장] 등) — 수식 사본이 그대로면 새 판으로 한 번 더
|
||||
if (!cp || e.status !== 409) throw e;
|
||||
const now = await call<Loaded>("");
|
||||
if (
|
||||
JSON.stringify([now.문서, now.끊음 ?? {}]) !==
|
||||
JSON.stringify([JSON.parse(saved), JSON.parse(savedCut)])
|
||||
)
|
||||
throw e;
|
||||
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
|
||||
});
|
||||
take(got);
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
if (cp && got.판) cp.onSaved?.(got.판);
|
||||
showToast(done, "success");
|
||||
} catch (e) {
|
||||
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
const back = (): void => {
|
||||
doc = JSON.parse(saved) as FormulaDoc;
|
||||
cut = JSON.parse(savedCut) as Docs;
|
||||
cache.merge({ 문서: { ...calls, ...cut } });
|
||||
};
|
||||
|
||||
const discard = async (): Promise<void> => {
|
||||
rows.reset();
|
||||
if (!doc || !dirty()) return void draw();
|
||||
back();
|
||||
openDef = null;
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
};
|
||||
|
||||
/** 문서 열기 — 같은 키면 그대로(다시 그림만) */
|
||||
const open = async (next: string): Promise<void> => {
|
||||
if (next === key && doc) return;
|
||||
rows.reset();
|
||||
const was = key;
|
||||
key = next;
|
||||
doc = answer = null;
|
||||
given = {};
|
||||
showOff = false;
|
||||
openDef = null;
|
||||
asked = "";
|
||||
cache = createDocCache();
|
||||
tables.clear();
|
||||
[cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""];
|
||||
draw();
|
||||
if (!next) {
|
||||
// 저장 전 새 로직 — 세 영역 빈 화면(누르면 입력 · 고친 것은 캐시 "" 열쇠)
|
||||
doc = blankDoc();
|
||||
saved = JSON.stringify(doc);
|
||||
keep(); // 옛 새 로직 고침(「""」 열쇠) 걷음
|
||||
recalc();
|
||||
return draw();
|
||||
}
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
|
||||
if (mine !== seq || key !== next) return;
|
||||
take(got);
|
||||
const all = drafts();
|
||||
// 저장 전 새 로직에서 고친 문서 — 페이지 [저장]으로 생긴 새 키(빈 문서)로 이어 감
|
||||
if (!was && got.새 && all[""] && !all[next]) {
|
||||
all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 };
|
||||
delete all[""];
|
||||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||||
}
|
||||
const draft = all[next];
|
||||
if (draft) doc = clone(draft);
|
||||
const kept = cp ? (drafts()[`${next}#끊음`] as unknown as Docs | undefined) : undefined;
|
||||
if (kept) cache.merge({ 문서: (cut = clone(kept)) });
|
||||
recalc();
|
||||
draw();
|
||||
} catch (e) {
|
||||
if (mine === seq)
|
||||
head.replaceChildren(
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const canLeave = async (): Promise<boolean> => {
|
||||
rows.flush();
|
||||
if (!dirty()) return true;
|
||||
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
|
||||
back();
|
||||
keep();
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */
|
||||
draw: (next: string): void => void open(next),
|
||||
canLeave,
|
||||
/** 시험 · 확인용 — 지금 문서 · 셈 */
|
||||
state: () => ({ doc, answer, dirty: dirty() }),
|
||||
};
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_sub.ts
|
||||
* 단가산출 연결 펼침(PLAN 802-3 · 802-4b-2 · 요구 659-3 · 796-2e 뜻) — 정의 영역에서 단가산출 기호를 열면 그 문서의 식 · 값 식
|
||||
* M01 = 읽기만 · M02 사본(`copy`) = 머리 「연결」 + [연결 끊기](이 사본 안 자기 문서로 굳힘 · 금액 그대로) ·
|
||||
* 「끊음」 + [다시 잇기](고친 것 버림 · 부름 문서로) · 끊은 문서 = 그 자리에서 식 줄 · 금액 줄 · 숫자 고침(`formula_doc_edit.ts`)
|
||||
* · 줄 편집 = 수식 영역과 같은 편집기(`formula_doc_math.ts` · 803-2a) · 맨 아래 계 줄
|
||||
* 셈 = 같은 계산기 · 연결 캐시(끊은 문서는 캐시 문서를 덮어씀 — 부른 쪽이 `merge`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { editNumber } from "@ui/ui_template_equation_edit";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc";
|
||||
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
|
||||
import { editLit, editRow, ensureVar, sweep } from "./formula_doc_edit";
|
||||
import { buildLines, sumRow, type RowRef } from "./formula_doc_lines";
|
||||
import { openMath } from "./formula_doc_math";
|
||||
import { evalNode, tryParse } from "./formula_doc_render";
|
||||
import { ft } from "./formula_doc_text";
|
||||
|
||||
export interface SubCopy {
|
||||
/** 층 쓰기 권한 */
|
||||
edit: boolean;
|
||||
/** 이 사본이 부르는 키(부름 · 끊음) */
|
||||
has: (key: string) => boolean;
|
||||
/** 끊은 문서(없으면 연결) */
|
||||
cut: (key: string) => FormulaDoc | undefined;
|
||||
onCut: (key: string) => void;
|
||||
onJoin: (key: string) => void;
|
||||
/** 끊은 문서 고침 — 캐시 · 셈 · 그림까지 부른 쪽이 */
|
||||
change: (key: string, fn: (d: FormulaDoc) => void) => void;
|
||||
}
|
||||
|
||||
export interface SubHooks {
|
||||
doc: FormulaDoc | null;
|
||||
answer: DocAnswer | null;
|
||||
cache: DocCache;
|
||||
copy?: SubCopy;
|
||||
}
|
||||
|
||||
/** 끊은 문서에서 편집 중인 줄 — 다음 그림에서 편집기를 엶 */
|
||||
let next: { key: string; ref: RowRef; at?: { x: number; y: number }; seed?: string } | null = null;
|
||||
|
||||
const subDoc = (cache: DocCache, k: string): FormulaDoc | null => {
|
||||
try {
|
||||
return cache.links.doc(k) ?? null;
|
||||
} catch {
|
||||
cache.takeMissing();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function linkHead(k: string, c: SubCopy): HTMLElement | null {
|
||||
if (!c.has(k)) return null;
|
||||
const cut = !!c.cut(k);
|
||||
const tools = c.edit
|
||||
? [
|
||||
createButton({
|
||||
label: cut ? ft("Relink") : ft("Unlink"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => (cut ? c.onJoin(k) : c.onCut(k)),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const head = el("div", {
|
||||
className: "fdoc__sublink",
|
||||
attrs: { "data-sub-link": cut ? "cut" : "linked" },
|
||||
children: [createBadge(cut ? ft("Cut") : ft("Linked"), cut ? "warning" : "info"), ...tools],
|
||||
});
|
||||
return head;
|
||||
}
|
||||
|
||||
/** 단가산출 연결 펼침 — 그 문서의 식 · 값 식(끊은 사본 문서면 고침) */
|
||||
export function subBlock(id: string, h: SubHooks): HTMLElement | null {
|
||||
const link: Link | undefined = h.doc?.변수?.find((v) => varId(v) === id)?.연결;
|
||||
if (link?.갈래 !== "단가산출" || !link.키 || link.키.includes("{")) return null;
|
||||
const k = link.키;
|
||||
const sub = subDoc(h.cache, k);
|
||||
if (!sub) return null;
|
||||
const inputs: Record<string, unknown> = {};
|
||||
for (const [name, t] of Object.entries(link.입력 ?? {})) {
|
||||
const n = tryParse(t);
|
||||
const v = n ? evalNode(n, h.answer?.값 ?? {}) : undefined;
|
||||
if (v !== undefined) inputs[name] = v;
|
||||
}
|
||||
const got = calcDoc(sub, h.cache.links, inputs);
|
||||
h.cache.takeMissing();
|
||||
const c = h.copy;
|
||||
const live = !!(c?.edit && c.cut(k));
|
||||
const put = (fn: (d: FormulaDoc) => void): void => c!.change(k, fn);
|
||||
const none = (): void => undefined;
|
||||
const editing = next?.key === k ? next : null;
|
||||
next = null;
|
||||
const box = el("div", { className: "fdoc__grid fdoc__sub" });
|
||||
const edit = (ref: RowRef | "end", at?: { x: number; y: number }): void => {
|
||||
if (ref !== "end") {
|
||||
next = { key: k, ref, at };
|
||||
return put(() => undefined);
|
||||
}
|
||||
const money = (sub.금액 ?? []).length;
|
||||
next = { key: k, ref: { kind: money ? "money" : "line", i: money || (sub.식 ?? []).length } };
|
||||
put((d) => {
|
||||
const last = d.금액?.[d.금액.length - 1];
|
||||
if (last) {
|
||||
d.금액!.push({
|
||||
이름: "",
|
||||
비목: last.비목 ?? "노무비",
|
||||
단가: "",
|
||||
...(last.묶음 ? { 묶음: last.묶음 } : {}),
|
||||
});
|
||||
next!.seed = `${last.비목 ?? "노무비"} `;
|
||||
} else (d.식 ??= []).push({ 기호: "", 식: "" });
|
||||
});
|
||||
};
|
||||
const mount = (host: HTMLElement, ref: RowRef, text: string): void => {
|
||||
const first = editing?.seed ?? text;
|
||||
queueMicrotask(() =>
|
||||
openMath(host, {
|
||||
text: first,
|
||||
at: editing?.at ?? "end",
|
||||
onLeave: (now) =>
|
||||
put((d) => {
|
||||
if (now !== first) editRow(d, ref.kind, ref.i, now);
|
||||
sweep(d);
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
box.append(
|
||||
...buildLines({
|
||||
admin: live,
|
||||
doc: sub,
|
||||
answer: got,
|
||||
vars: new Map((sub.변수 ?? []).map((v) => [varId(v), v])),
|
||||
marks: new Map(),
|
||||
editing: editing?.ref ?? null,
|
||||
mount,
|
||||
onEditAt: live ? edit : none,
|
||||
onLit: live ? (w, i, at, v) => put((d) => editLit(d, w, i, at, v)) : none,
|
||||
onPickVar: (vid, num) => {
|
||||
const v = sub.변수?.find((x) => varId(x) === vid);
|
||||
if (!live || !num || v?.연결?.갈래 !== "숫자") return;
|
||||
editNumber(num, (t) => {
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
put((d) => (ensureVar(d, vid).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t }));
|
||||
});
|
||||
},
|
||||
onGroupName: none,
|
||||
onRedraw: () => put((d) => sweep(d)),
|
||||
}).cells,
|
||||
);
|
||||
if ((sub.금액 ?? []).length)
|
||||
box.append(...sumRow(ft("Sum"), got.셈됨 ? (got as unknown as Record<string, unknown>) : null));
|
||||
if (sub.결과) {
|
||||
const r = got.결과;
|
||||
box.append(
|
||||
el("div", { className: "fdoc__cell fdoc__total", text: ft("Result") }),
|
||||
el("div", {
|
||||
className: "fdoc__cell fdoc__total",
|
||||
text: typeof r === "number" ? `${eqText(r)} ${sub.결과단위 ?? ""}` : "—",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const head = c ? linkHead(k, c) : null;
|
||||
if (!head) return box;
|
||||
return el("div", { attrs: { "data-sub": k }, children: [head, box] });
|
||||
}
|
||||
@@ -299,3 +299,16 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 옵션 가로(옛 수식 문서 CSS 에서 옮김 · 804-6) */
|
||||
.fdoc__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fdoc__options > * {
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* =============================================================================
|
||||
* line_stack_screen.ts
|
||||
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
|
||||
* 위 = 옵션 가로(수식 탭과 같은 부품 `formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기)
|
||||
* 위 = 옵션 가로(`formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기) · M02 사본 [단가 갱신] · [재생성]
|
||||
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
|
||||
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 수식 탭과 같은 부품)
|
||||
* 정본 = 수식 문서(수식 탭과 같은 문서 · 같은 길 · 같은 캐시 열쇠 — 탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기`
|
||||
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계)
|
||||
* 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭
|
||||
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
|
||||
* 고침 = 캐시(sessionStorage)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
|
||||
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(수식 탭이 끊은 문서 그대로), 판} · [단가 갱신] · [재생성]은 수식 탭
|
||||
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
|
||||
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -31,7 +32,15 @@ import {
|
||||
type StackDoc,
|
||||
type StackItem,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import { copyBadges, type CopyMark, type CopyOptions } from "./formula_doc_copy";
|
||||
import {
|
||||
askJob,
|
||||
copyBadges,
|
||||
copyTools,
|
||||
type CopyJob,
|
||||
type CopyMark,
|
||||
type CopyOptions,
|
||||
type JobReply,
|
||||
} from "./formula_doc_copy";
|
||||
import { docFoot } from "./formula_doc_foot";
|
||||
import { buildOptions } from "./formula_doc_options";
|
||||
import { ft } from "./formula_doc_text";
|
||||
@@ -40,14 +49,13 @@ import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { buildEdit } from "./line_stack_edit";
|
||||
import { buildOut } from "./line_stack_out";
|
||||
import { lt } from "./line_stack_text";
|
||||
import "./formula_doc.css";
|
||||
import "./line_stack.css";
|
||||
|
||||
export interface StackScreenOptions {
|
||||
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links */
|
||||
base: string;
|
||||
admin: boolean;
|
||||
/** sessionStorage 열쇠 — 수식 탭과 같게(같은 문서 고침을 이어 받음) */
|
||||
/** sessionStorage 열쇠 */
|
||||
cache: string;
|
||||
copy?: CopyOptions;
|
||||
/** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */
|
||||
@@ -122,7 +130,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
return data;
|
||||
};
|
||||
|
||||
// ── 캐시(수식 탭과 같은 열쇠) ──
|
||||
// ── 캐시 ──
|
||||
const drafts = (): Record<string, FormulaDoc> => {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
|
||||
@@ -191,7 +199,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
/** 표 하나 — 수식 탭과 같은 길(`/table`) · 받은 표는 계산기 캐시에도 */
|
||||
/** 표 하나(`/table`) — 받은 표는 계산기 캐시에도 */
|
||||
const tableOf = (k: string): Promise<DocTable | null> => {
|
||||
if (!tables.has(k))
|
||||
tables.set(
|
||||
@@ -264,6 +272,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
const tools =
|
||||
edit() && key
|
||||
? [
|
||||
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
|
||||
createButton({
|
||||
label: ft("Discard"),
|
||||
variant: "ghost",
|
||||
@@ -356,22 +365,44 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
version = got.판 ?? "";
|
||||
};
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!doc || !dirty()) return;
|
||||
const kept = drafts()[`${key}#끊음`] as unknown as Docs | undefined; // 수식 탭이 끊은 문서 고침
|
||||
const send = cp ? { 문서: doc, 끊음: kept ?? cut, 판: version } : { 문서: doc };
|
||||
/** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */
|
||||
const runJob = async (job: CopyJob): Promise<void> => {
|
||||
if (!doc || !cp) return;
|
||||
if (dirty()) return void showToast(ft("Dirty_First"), "warning");
|
||||
showLoadingOverlay();
|
||||
let got: JobReply;
|
||||
try {
|
||||
got = await call<JobReply>("/preview", { 할일: job });
|
||||
} catch (e) {
|
||||
hideLoadingOverlay();
|
||||
return void showToast(
|
||||
`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
hideLoadingOverlay();
|
||||
if (await askJob(job, got, cache.names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
|
||||
};
|
||||
|
||||
const save = async (job?: Record<string, unknown>, done = ft("Saved")): Promise<void> => {
|
||||
if (!doc || (!job && !dirty())) return;
|
||||
const send = job ?? (cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc });
|
||||
const was = saved;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const got = await call<Loaded>("/save", send);
|
||||
const got = await call<Loaded>("/save", send).catch(async (e: { status?: number }) => {
|
||||
// 사본 판 바뀜(페이지 [저장] 등) — 문서가 받은 그대로면 새 판으로 한 번 더
|
||||
if (!cp || e.status !== 409) throw e;
|
||||
const now = await call<Loaded>("");
|
||||
if (JSON.stringify(now.문서) !== was) throw e;
|
||||
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
|
||||
});
|
||||
take(got);
|
||||
const all = drafts();
|
||||
delete all[`${key}#끊음`];
|
||||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
if (cp && got.판) cp.onSaved?.(got.판);
|
||||
showToast(ft("Saved"), "success");
|
||||
showToast(done, "success");
|
||||
} catch (e) {
|
||||
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
|
||||
} finally {
|
||||
@@ -426,8 +457,6 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||||
}
|
||||
if (all[next]) doc = clone(all[next] as StackDoc);
|
||||
if (cp && all[`${next}#끊음`])
|
||||
cache.merge({ 문서: (cut = clone(all[`${next}#끊음`] as unknown as Docs)) });
|
||||
recalc();
|
||||
draw();
|
||||
} catch (e) {
|
||||
@@ -443,7 +472,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
|
||||
const canLeave = async (): Promise<boolean> => {
|
||||
if (!dirty()) return true;
|
||||
if (!(key in drafts())) return true; // 수식 탭에서 이미 버림
|
||||
if (!(key in drafts())) return true; // 다른 화면에서 이미 버림
|
||||
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
|
||||
doc = JSON.parse(saved) as StackDoc;
|
||||
keep();
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/sheet_formula_cell.ts
|
||||
* 줄 식 시트 식 칸 한 벌(PLAN 792 · 794-1) — M02 단가산출 시트 · M01 로직 시트가 같이 씀.
|
||||
* 칠 때마다 검사(`check` · 150 ms 쉼 · 앞 요청 끊음) → 틀리면 빨간 칸 + 까닭 · 알림 한 줄
|
||||
* 바꿀 때(Enter · 떠남) — 빈 칸 · 원래 식 그대로 = `put(null)` · 되면 `put(엔진 식)` · 틀리면 안 넣음
|
||||
* 치는 중 글 = `drafts`(다시 그려도 안 잃음) · 부품 = 입력 · 글 · 서버 검사 길은 부르는 쪽 몫
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
|
||||
const TEXT = {
|
||||
Formula: ["식", "Formula"],
|
||||
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
|
||||
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
|
||||
Checking: ["검사 중", "Checking…"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
|
||||
/** 검사 답 — 되나 · 까닭 · 알림(없으면 빈) · 엔진 식 */
|
||||
export interface FormulaVerdict {
|
||||
되나: boolean;
|
||||
까닭: string[];
|
||||
알림?: string[];
|
||||
식: string | null;
|
||||
}
|
||||
|
||||
export interface FormulaCellOptions {
|
||||
/** 시트 칸 열쇠(초점 되찾기 · 치는 중 글) */
|
||||
key: string;
|
||||
/** 칸 그릇 이름표 값(`data-sheet-fx`) */
|
||||
spot: string;
|
||||
shown: string;
|
||||
/** 원래 식 글 — 그대로 두면 `put(null)` */
|
||||
origin: string;
|
||||
drafts: Map<string, string>;
|
||||
check: (text: string, signal: AbortSignal) => Promise<FormulaVerdict>;
|
||||
put: (engine: string | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function createFormulaCell(o: FormulaCellOptions): HTMLElement {
|
||||
const box = createInputField({
|
||||
value: o.drafts.get(o.key) ?? o.shown,
|
||||
compact: true,
|
||||
size: 24,
|
||||
placeholder: t("Hint"),
|
||||
});
|
||||
box.input.setAttribute("aria-label", t("Formula"));
|
||||
box.input.dataset.sheetCell = o.key;
|
||||
box.input.spellcheck = false;
|
||||
const note = createText("", "muted", 12);
|
||||
note.dataset.sheetCheck = o.key;
|
||||
let timer = 0;
|
||||
let ctl: AbortController | null = null;
|
||||
const run = (text: string): Promise<FormulaVerdict | null> => {
|
||||
ctl?.abort();
|
||||
const mine = (ctl = new AbortController());
|
||||
note.textContent = t("Checking");
|
||||
return o.check(text, mine.signal).then(
|
||||
(got) => {
|
||||
if (mine !== ctl) return got;
|
||||
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
|
||||
note.textContent = (got.알림 ?? []).join(" · ");
|
||||
return got;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (mine !== ctl || mine.signal.aborted) return null;
|
||||
box.setError(error instanceof Error ? error.message : String(error));
|
||||
note.textContent = "";
|
||||
return null;
|
||||
},
|
||||
);
|
||||
};
|
||||
box.input.addEventListener("input", () => {
|
||||
o.drafts.set(o.key, box.input.value);
|
||||
window.clearTimeout(timer);
|
||||
const text = box.input.value.trim();
|
||||
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
|
||||
timer = window.setTimeout(() => void run(text), 150);
|
||||
});
|
||||
box.input.addEventListener("change", async () => {
|
||||
window.clearTimeout(timer);
|
||||
const text = box.input.value.trim();
|
||||
if (!text || text === o.origin) {
|
||||
o.drafts.delete(o.key);
|
||||
return o.put(null);
|
||||
}
|
||||
const got = await run(text);
|
||||
if (!got?.되나 || !got.식) return void (note.textContent = t("Bad"));
|
||||
o.drafts.delete(o.key);
|
||||
o.put(got.식);
|
||||
});
|
||||
return el("div", {
|
||||
className: o.className ?? "m02-sks__fx",
|
||||
attrs: { "data-sheet-fx": o.spot },
|
||||
children: [box.root, note],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user