feat(714-3): M01 텍스트 수식 입력 단추 — 줄이 쓰는 시험 입력(표 찾기 행 열쇠 · 이름 조각)을 값 식 아래 단추로 · 누르면 그 자리 고르기/글 칸 · 바꾸면 시험 계산 입력 고침 + 다시 셈 · 정본 · 캐시 안 바뀜
- Formula.ts usedInputs(순수) · inputChip(부품 상자 단추 · 고르기 · 입력 칸) - Detail → Formula 에 입력 · 값 · onInput 넘김 · LogicLab syncLeft(loud) = 흐름 예시값도 같은 길 · 줄 수 그대로(627) - Logic_Api TextPiece 에 이름 조각 값 칸 - 새 지킴 시험 test_714_3_formula_inputs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,13 @@
|
||||
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
|
||||
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
|
||||
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
|
||||
* 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
|
||||
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
@@ -17,6 +20,77 @@ export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean };
|
||||
|
||||
export interface FormulaContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
/** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */
|
||||
inputs?: LogicInput[];
|
||||
values?: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
/* ── 순수 셈: 줄이 쓰는 입력 ── */
|
||||
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
|
||||
export function usedInputs(
|
||||
line: Pick<TextLine, "조각" | "요소조각">,
|
||||
names: string[],
|
||||
): [string, string][] {
|
||||
const out = new Map<string, string>();
|
||||
for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) {
|
||||
const row = p.kind === "표" ? p.참조?.행 : null;
|
||||
if (row && typeof row === "object")
|
||||
for (const [k, v] of Object.entries(row))
|
||||
if (names.includes(k) && !out.has(k)) out.set(k, String(v));
|
||||
if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글))
|
||||
out.set(p.글, String(p.값));
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */
|
||||
function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement {
|
||||
const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } });
|
||||
const chip = createButton({
|
||||
label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`,
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: (ev) => {
|
||||
ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게
|
||||
let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게
|
||||
const done = (v: string | null): void => {
|
||||
if (over) return;
|
||||
over = true;
|
||||
if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim());
|
||||
else wrap.replaceChildren(chip);
|
||||
};
|
||||
let field: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
const pick = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
value: shown,
|
||||
size: 24,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => done(v),
|
||||
});
|
||||
pick.select.addEventListener("blur", () => done(null));
|
||||
field = pick.root;
|
||||
} else {
|
||||
const box = createInputField({ value: shown, size: 24 });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.addEventListener("keydown", (k) => {
|
||||
if (k.isComposing) return;
|
||||
if (k.key === "Enter") done(box.input.value);
|
||||
if (k.key === "Escape") (k.preventDefault(), done(null));
|
||||
});
|
||||
box.input.addEventListener("blur", () => done(box.input.value));
|
||||
field = box.root;
|
||||
}
|
||||
field.addEventListener("click", (e) => e.stopPropagation());
|
||||
wrap.replaceChildren(field);
|
||||
field.querySelector<HTMLElement>("select, input")?.focus();
|
||||
},
|
||||
});
|
||||
chip.title = tl("Formula_Var");
|
||||
wrap.append(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
|
||||
@@ -47,6 +121,27 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
],
|
||||
});
|
||||
const rows: HTMLElement[] = [];
|
||||
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
|
||||
const vars = (line: TextLine): HTMLElement[] => {
|
||||
const used = usedInputs(
|
||||
line,
|
||||
specs.map((x) => x.이름),
|
||||
);
|
||||
if (!used.length || !ctx.onInput) return [];
|
||||
const onInput = ctx.onInput;
|
||||
return [
|
||||
el("div", {
|
||||
className: "m01lab-fx__vars",
|
||||
children: used.map(([name, was]) =>
|
||||
inputChip(
|
||||
specs.find((x) => x.이름 === name)!,
|
||||
ctx.values?.[name] || was,
|
||||
(v) => onInput(name, v),
|
||||
),
|
||||
),
|
||||
}),
|
||||
];
|
||||
};
|
||||
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
|
||||
const light = (pair: string, on: boolean): void =>
|
||||
rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on));
|
||||
@@ -64,6 +159,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
children: calculated
|
||||
? [
|
||||
el("div", { text: `${line.이름} : ${line.글}` }),
|
||||
...vars(line),
|
||||
...(line.까닭
|
||||
? [
|
||||
el("div", {
|
||||
|
||||
Reference in New Issue
Block a user