Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
698 lines
24 KiB
TypeScript
698 lines
24 KiB
TypeScript
/* =============================================================================
|
||
* M02_CF_FuncPanel.ts
|
||
* 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) ·
|
||
* 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류
|
||
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_CF_Model 타입 그대로
|
||
* 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음)
|
||
* 글 [한, 영] 짝은 아래 TEXT
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||
import type {
|
||
CalcValues,
|
||
SpreadsheetHandle,
|
||
Workbook,
|
||
} from "../../A00_Common/spreadsheet/spreadsheet";
|
||
import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_CF_Model";
|
||
import "./M02_CF_FuncPanel.css";
|
||
|
||
const TEXT = {
|
||
Unit: ["단위", "Unit"],
|
||
Cell: ["고른 칸", "Selected cell"],
|
||
Got: ["받은 알약", "Received pills"],
|
||
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
|
||
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
|
||
Placed: ["놓임", "placed"],
|
||
Free: ["놓을 칸 없음", "not placed"],
|
||
Busy: ["이미 쓰는 칸", "That cell is already in use"],
|
||
Locked: [
|
||
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
|
||
"This cell is filled by the wire — cannot edit",
|
||
],
|
||
Vars: ["변수", "Variables"],
|
||
MakeVar: ["변수 만들기", "Make variable"],
|
||
NoVars: ["아직 변수가 없음", "No variables yet"],
|
||
VarName: ["이름", "Name"],
|
||
VarType: ["갈래", "Type"],
|
||
Number: ["숫자", "Number"],
|
||
Choice: ["선택", "Choice"],
|
||
Text: ["글", "Text"],
|
||
VarUnit: ["단위", "Unit"],
|
||
VarMin: ["최소", "Min"],
|
||
VarMax: ["최대", "Max"],
|
||
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
|
||
VarExample: ["예시값", "Example value"],
|
||
VarCell: ["붙을 칸", "Attach to"],
|
||
Make: ["만들기", "Make"],
|
||
Cancel: ["취소", "Cancel"],
|
||
Remove: ["지움", "Remove"],
|
||
NeedName: ["이름을 적음", "Enter a name"],
|
||
SameName: ["같은 이름이 이미 있음", "That name already exists"],
|
||
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
|
||
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
|
||
OutBtn: ["출력 칸으로", "Make output cell"],
|
||
OutNow: ["출력", "Output"],
|
||
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
|
||
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
|
||
Frames: ["틀", "Templates"],
|
||
FrameMul: ["곱하기", "Multiply"],
|
||
FrameMore: ["할증", "Markup"],
|
||
FrameDiv: ["나누기", "Divide"],
|
||
FrameIf: ["만약", "If"],
|
||
FrameMin: ["최소", "Min"],
|
||
FrameMax: ["최대", "Max"],
|
||
FrameUp: ["올림", "Round up"],
|
||
FrameLook: ["표에서 찾기", "Look up in table"],
|
||
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
|
||
ReadOnly: ["읽기만", "Read only"],
|
||
Loading: ["시트를 불러오는 중", "Loading sheet"],
|
||
Problem: ["⚠ ", "⚠ "],
|
||
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
|
||
Err_DIV0: ["0으로 나눔", "divides by zero"],
|
||
Err_NAME: ["알 수 없는 이름", "unknown name"],
|
||
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
|
||
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
|
||
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
|
||
Err_NA: ["찾는 값이 없음", "value not found"],
|
||
Err_OTHER: ["수식이 틀림", "formula is wrong"],
|
||
} as const;
|
||
|
||
const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||
|
||
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
|
||
const FRAMES: [keyof typeof TEXT, string][] = [
|
||
["FrameMul", "=B1*B2"],
|
||
["FrameMore", "=B1*(1+B2/100)"],
|
||
["FrameDiv", "=B1/B2"],
|
||
["FrameIf", '=IF(B3="급",10,0)'],
|
||
["FrameMin", "=MIN(B1,B2)"],
|
||
["FrameMax", "=MAX(B1,B2)"],
|
||
["FrameUp", "=ROUNDUP(B4,3)"],
|
||
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
|
||
];
|
||
|
||
const ERR_KEY: Record<string, keyof typeof TEXT> = {
|
||
"#DIV/0!": "Err_DIV0",
|
||
"#NAME?": "Err_NAME",
|
||
"#REF!": "Err_REF",
|
||
"#VALUE!": "Err_VALUE",
|
||
"#CYCLE!": "Err_CYCLE",
|
||
"#N/A": "Err_NA",
|
||
};
|
||
|
||
type Handlers = { change(lg: CfLogic): void };
|
||
|
||
interface VarForm {
|
||
name: string;
|
||
type: CfVariable["type"];
|
||
unit: string;
|
||
min: string;
|
||
max: string;
|
||
choices: string;
|
||
example: string;
|
||
why: string;
|
||
}
|
||
|
||
interface State {
|
||
boxId: string;
|
||
lg: CfLogic;
|
||
on: Handlers;
|
||
readOnly: boolean;
|
||
handle: SpreadsheetHandle | null;
|
||
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
|
||
snap: CalcValues;
|
||
/** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */
|
||
json: string;
|
||
cell: string;
|
||
form: VarForm | null;
|
||
parts: {
|
||
head: HTMLElement;
|
||
got: HTMLElement;
|
||
vars: HTMLElement;
|
||
bar: HTMLElement;
|
||
msg: HTMLElement;
|
||
sheet: HTMLElement;
|
||
};
|
||
observer: MutationObserver | null;
|
||
redraw(): void;
|
||
}
|
||
|
||
const states = new WeakMap<HTMLElement, State>();
|
||
let sheetModule: Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> | null = null;
|
||
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
|
||
const loadSheet = (): Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> =>
|
||
(sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet"));
|
||
|
||
const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId);
|
||
const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func;
|
||
const bookOf = (func: CfFunc): Workbook => ({
|
||
종류: "통합문서",
|
||
판: 1,
|
||
열: "cf",
|
||
서식: [{}],
|
||
시트: [structuredClone(func.sheet)],
|
||
활성: func.sheet.id,
|
||
});
|
||
const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet);
|
||
|
||
/* ------------------------------ 바꾸기 ------------------------------ */
|
||
|
||
function patchBox(st: State, make: (b: CfBox) => CfBox): void {
|
||
const next: CfLogic = {
|
||
...st.lg,
|
||
boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)),
|
||
};
|
||
st.lg = next;
|
||
st.on.change(next);
|
||
}
|
||
|
||
const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void =>
|
||
patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b));
|
||
|
||
const usedCells = (f: CfFunc): Map<string, string> => {
|
||
const m = new Map<string, string>();
|
||
for (const i of f.inputs) m.set(i.cell, i.port);
|
||
for (const v of f.variables) if (v.cell) m.set(v.cell, v.name);
|
||
if (f.output.cell) m.set(f.output.cell, f.output.name);
|
||
return m;
|
||
};
|
||
|
||
function placePort(st: State, port: string, cell: string): void {
|
||
const f = funcOf(st);
|
||
if (!f || st.readOnly) return;
|
||
const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell);
|
||
if (other) return void showToast(tf("Busy"), "error");
|
||
patchFunc(st, (x) => ({
|
||
...x,
|
||
inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }],
|
||
}));
|
||
}
|
||
|
||
function makeVariable(st: State): void {
|
||
const form = st.form;
|
||
const f = funcOf(st);
|
||
if (!form || !f) return;
|
||
const name = form.name.trim();
|
||
const fail = (key: keyof typeof TEXT): void => {
|
||
form.why = tf(key);
|
||
drawVars(st);
|
||
};
|
||
if (!name) return fail("NeedName");
|
||
const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)];
|
||
const mine = f.variables.find((v) => v.name === name);
|
||
if (taken.includes(name) && !mine) return fail("SameName");
|
||
const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) });
|
||
if (busy.has(st.cell)) return fail("Busy");
|
||
let example: number | string | null = form.example.trim() === "" ? null : form.example.trim();
|
||
const v: CfVariable = {
|
||
name,
|
||
type: form.type,
|
||
unit: form.unit.trim() || "-",
|
||
example: null,
|
||
cell: st.cell,
|
||
};
|
||
if (form.type === "number") {
|
||
if (example !== null) {
|
||
const n = Number(String(example).replace(/,/g, ""));
|
||
if (!Number.isFinite(n)) return fail("NotNumber");
|
||
example = n;
|
||
}
|
||
const lo = form.min.trim() === "" ? null : Number(form.min);
|
||
const hi = form.max.trim() === "" ? null : Number(form.max);
|
||
if (lo !== null && hi !== null) {
|
||
if (!(lo < hi)) return fail("BadRange");
|
||
v.range = [lo, hi];
|
||
}
|
||
} else if (form.type === "choice") {
|
||
v.choices = form.choices
|
||
.split(",")
|
||
.map((x) => x.trim())
|
||
.filter(Boolean);
|
||
}
|
||
v.example = example;
|
||
const cell = st.cell;
|
||
st.form = null;
|
||
patchFunc(st, (x) => {
|
||
const sheet = structuredClone(x.sheet);
|
||
if (example !== null) sheet.칸[cell] = { 값: example };
|
||
return {
|
||
...x,
|
||
sheet,
|
||
inputs: x.inputs.filter((i) => i.cell !== cell),
|
||
variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v],
|
||
};
|
||
});
|
||
}
|
||
|
||
/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */
|
||
function incoming(st: State): { port: string; unit: string }[] {
|
||
const out = new Map<string, string>();
|
||
for (const l of st.lg.links) {
|
||
if (l.to.box !== st.boxId || out.has(l.to.name)) continue;
|
||
const src = st.lg.boxes.find((b) => b.id === l.from.box);
|
||
const unit =
|
||
src?.func && src.func.output.name === l.from.name
|
||
? src.func.output.unit
|
||
: (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ??
|
||
src?.input?.variables.find((v) => v.name === l.from.name)?.unit ??
|
||
src?.unit ??
|
||
"-");
|
||
out.set(l.to.name, unit || "-");
|
||
}
|
||
return [...out].map(([port, unit]) => ({ port, unit }));
|
||
}
|
||
|
||
function problems(st: State): string[] {
|
||
const f = funcOf(st);
|
||
if (!f) return [];
|
||
const out: string[] = [];
|
||
for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) {
|
||
if (typeof v === "object" && "오류" in v) {
|
||
out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`);
|
||
}
|
||
}
|
||
for (const p of incoming(st)) {
|
||
if (!f.inputs.some((i) => i.port === p.port)) out.push(`${p.port}${tf("Unplaced")}`);
|
||
}
|
||
if (!f.output.cell) out.push(tf("OutNone"));
|
||
return out;
|
||
}
|
||
|
||
/* ------------------------------ 칸 위 표시 ------------------------------ */
|
||
|
||
function applyMarks(st: State): void {
|
||
const f = funcOf(st);
|
||
if (!f) return;
|
||
const marks = new Map<string, { kind: string; label: string }>();
|
||
for (const i of f.inputs) marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` });
|
||
for (const v of f.variables)
|
||
if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` });
|
||
if (f.output.cell)
|
||
marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` });
|
||
const locked = new Set(f.inputs.map((i) => i.cell));
|
||
const list = problems(st);
|
||
const bad = new Set(list.map((p) => p.split(" ")[0]));
|
||
for (const c of st.parts.sheet.querySelectorAll<HTMLElement>(".aislo-cell")) {
|
||
const addr = c.dataset.addr ?? "";
|
||
for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a];
|
||
const m = marks.get(addr);
|
||
if (m) {
|
||
c.dataset.cfKind = m.kind;
|
||
c.dataset.cfName = m.label;
|
||
}
|
||
if (locked.has(addr)) c.dataset.cfLock = "🔒";
|
||
if (bad.has(addr)) c.dataset.cfErr = "1";
|
||
}
|
||
st.parts.msg.textContent = list.length
|
||
? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}`
|
||
: "";
|
||
st.parts.msg.hidden = !list.length;
|
||
}
|
||
|
||
/* ------------------------------ 위 줄들 ------------------------------ */
|
||
|
||
function drawHead(st: State): void {
|
||
const box = boxOf(st);
|
||
const unit = el("input", {
|
||
className: "ui-input m01-cfpanel-unit",
|
||
attrs: { type: "text", "aria-label": tf("Unit") },
|
||
});
|
||
unit.value = box?.unit ?? "-";
|
||
if (st.readOnly) unit.readOnly = true;
|
||
unit.addEventListener("change", () => {
|
||
const v = unit.value.trim() || "-";
|
||
unit.value = v;
|
||
patchBox(st, (b) => ({
|
||
...b,
|
||
unit: v,
|
||
func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func,
|
||
}));
|
||
});
|
||
st.parts.head.replaceChildren(
|
||
el("strong", { className: "m01-cfpanel-title", text: box?.name ?? "" }),
|
||
el("span", { className: "m01-cfpanel-cell", text: `${tf("Cell")} ${st.cell}` }),
|
||
el("label", {
|
||
className: "m01-cfpanel-unitbox",
|
||
children: [el("span", { className: "m01-cfpanel-note", text: tf("Unit") }), unit],
|
||
}),
|
||
);
|
||
}
|
||
|
||
function drawGot(st: State): void {
|
||
const f = funcOf(st);
|
||
const items = incoming(st);
|
||
const chips = items.map(({ port, unit }) => {
|
||
const at = f?.inputs.find((i) => i.port === port)?.cell;
|
||
const b = el("button", {
|
||
className: `m01-cfpanel-chip is-in${at ? " is-placed" : ""}`,
|
||
text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`,
|
||
attrs: { type: "button", draggable: st.readOnly ? "false" : "true" },
|
||
});
|
||
b.addEventListener("click", () => placePort(st, port, st.cell));
|
||
b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port));
|
||
return b;
|
||
});
|
||
st.parts.got.replaceChildren(
|
||
el("span", { className: "m01-cfpanel-label", text: tf("Got") }),
|
||
...(chips.length ? chips : [el("span", { className: "m01-cfpanel-note", text: tf("NoGot") })]),
|
||
...(chips.length && !st.readOnly
|
||
? [el("span", { className: "m01-cfpanel-note", text: tf("GotHint") })]
|
||
: []),
|
||
);
|
||
}
|
||
|
||
function formField(
|
||
st: State,
|
||
label: keyof typeof TEXT,
|
||
key: "name" | "unit" | "min" | "max" | "choices" | "example",
|
||
cls = "",
|
||
): HTMLElement {
|
||
const form = st.form!;
|
||
const input = el("input", {
|
||
className: `ui-input m01-cfpanel-field ${cls}`,
|
||
attrs: { type: "text", "aria-label": tf(label) },
|
||
});
|
||
input.value = form[key];
|
||
input.addEventListener("input", () => (form[key] = input.value));
|
||
input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st));
|
||
return el("label", {
|
||
className: "m01-cfpanel-fieldbox",
|
||
children: [el("span", { className: "m01-cfpanel-note", text: tf(label) }), input],
|
||
});
|
||
}
|
||
|
||
function drawVars(st: State): void {
|
||
const f = funcOf(st);
|
||
const row: HTMLElement[] = [el("span", { className: "m01-cfpanel-label", text: tf("Vars") })];
|
||
for (const v of f?.variables ?? []) {
|
||
const chip = el("span", {
|
||
className: "m01-cfpanel-chip is-var",
|
||
children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`],
|
||
});
|
||
if (!st.readOnly) {
|
||
chip.append(
|
||
createButton({
|
||
label: "×",
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () =>
|
||
patchFunc(st, (x) => ({
|
||
...x,
|
||
variables: x.variables.filter((o) => o.name !== v.name),
|
||
})),
|
||
}),
|
||
);
|
||
chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`);
|
||
}
|
||
row.push(chip);
|
||
}
|
||
if (!f?.variables.length)
|
||
row.push(el("span", { className: "m01-cfpanel-note", text: tf("NoVars") }));
|
||
const parts: HTMLElement[] = [el("div", { className: "m01-cfpanel-row", children: row })];
|
||
if (!st.readOnly) {
|
||
const form = st.form;
|
||
if (!form) {
|
||
parts.push(
|
||
el("div", {
|
||
className: "m01-cfpanel-row",
|
||
children: [
|
||
createButton({
|
||
label: tf("MakeVar"),
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => {
|
||
st.form = {
|
||
name: "",
|
||
type: "number",
|
||
unit: "",
|
||
min: "",
|
||
max: "",
|
||
choices: "",
|
||
example: "",
|
||
why: "",
|
||
};
|
||
drawVars(st);
|
||
},
|
||
}),
|
||
],
|
||
}),
|
||
);
|
||
} else {
|
||
const type = el("select", {
|
||
className: "ui-select m01-cfpanel-field",
|
||
attrs: { "aria-label": tf("VarType") },
|
||
});
|
||
for (const [v, k] of [
|
||
["number", "Number"],
|
||
["choice", "Choice"],
|
||
["text", "Text"],
|
||
] as const) {
|
||
type.append(el("option", { attrs: { value: v }, text: tf(k) }));
|
||
}
|
||
type.value = form.type;
|
||
type.addEventListener("change", () => {
|
||
form.type = type.value as CfVariable["type"];
|
||
drawVars(st);
|
||
});
|
||
parts.push(
|
||
el("div", {
|
||
className: "m01-cfpanel-row m01-cfpanel-form",
|
||
children: [
|
||
formField(st, "VarName", "name"),
|
||
el("label", {
|
||
className: "m01-cfpanel-fieldbox",
|
||
children: [el("span", { className: "m01-cfpanel-note", text: tf("VarType") }), type],
|
||
}),
|
||
formField(st, "VarUnit", "unit"),
|
||
...(form.type === "number"
|
||
? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")]
|
||
: []),
|
||
...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []),
|
||
formField(st, "VarExample", "example"),
|
||
el("span", { className: "m01-cfpanel-cell", text: `${tf("VarCell")} ${st.cell}` }),
|
||
createButton({
|
||
label: tf("Make"),
|
||
variant: "filled",
|
||
size: "sm",
|
||
onClick: () => makeVariable(st),
|
||
}),
|
||
createButton({
|
||
label: tf("Cancel"),
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => ((st.form = null), drawVars(st)),
|
||
}),
|
||
...(form.why
|
||
? [el("span", { className: "m01-cfpanel-why", text: `⚠ ${form.why}` })]
|
||
: []),
|
||
],
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
st.parts.vars.replaceChildren(...parts);
|
||
}
|
||
|
||
function drawBar(st: State): void {
|
||
const f = funcOf(st);
|
||
if (st.readOnly) {
|
||
st.parts.bar.replaceChildren(
|
||
el("span", { className: "m01-cfpanel-note", text: tf("ReadOnly") }),
|
||
);
|
||
return;
|
||
}
|
||
const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone");
|
||
st.parts.bar.replaceChildren(
|
||
el("div", {
|
||
className: "m01-cfpanel-row",
|
||
children: [
|
||
createButton({
|
||
label: tf("OutBtn"),
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => setOutput(st, st.cell),
|
||
}),
|
||
el("span", { className: "m01-cfpanel-chip is-out", text: outNow }),
|
||
el("span", { className: "m01-cfpanel-note", text: tf("OutOnly") }),
|
||
],
|
||
}),
|
||
el("div", {
|
||
className: "m01-cfpanel-row",
|
||
children: [
|
||
el("span", { className: "m01-cfpanel-label", text: tf("Frames") }),
|
||
...FRAMES.map(([key, formula]) =>
|
||
createButton({
|
||
label: tf(key),
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => putFormula(st, formula),
|
||
}),
|
||
),
|
||
el("span", { className: "m01-cfpanel-note", text: tf("FrameHint") }),
|
||
],
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
|
||
function setOutput(st: State, cell: string): void {
|
||
const f = funcOf(st);
|
||
if (!f) return;
|
||
if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) {
|
||
return void showToast(tf("Busy"), "error");
|
||
}
|
||
const row = cell.replace(/^[A-Z]+/, "");
|
||
const label = f.sheet.칸[`A${row}`];
|
||
const name = String(label?.값 ?? f.output.name ?? "").trim() || cell;
|
||
patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } }));
|
||
}
|
||
|
||
/* ------------------------------ 편집기 ------------------------------ */
|
||
|
||
/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */
|
||
function putFormula(st: State, formula: string): void {
|
||
const root = st.parts.sheet;
|
||
const bar = root.querySelector<HTMLTextAreaElement>(".ss-formula");
|
||
const addr = root.querySelector<HTMLInputElement>(".ss-addr");
|
||
if (!bar || !addr || st.readOnly) return;
|
||
const at = st.cell;
|
||
bar.focus();
|
||
bar.value = formula;
|
||
bar.dispatchEvent(new Event("input", { bubbles: true }));
|
||
bar.dispatchEvent(
|
||
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
|
||
);
|
||
addr.value = at;
|
||
addr.dispatchEvent(
|
||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
||
);
|
||
}
|
||
|
||
/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */
|
||
function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] {
|
||
for (const cell of Object.values(sheet.칸)) delete cell.서식;
|
||
return sheet;
|
||
}
|
||
|
||
/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */
|
||
function onBook(st: State, book: Workbook): void {
|
||
const f = funcOf(st);
|
||
if (!f) return;
|
||
const sheet = plain(structuredClone(book.시트[0]));
|
||
const hit = f.inputs.find(
|
||
(i) => JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]),
|
||
);
|
||
if (hit) {
|
||
showToast(tf("Locked"), "error");
|
||
st.json = sheetJson(f);
|
||
return st.handle?.setDoc(bookOf(f));
|
||
}
|
||
st.json = JSON.stringify(sheet);
|
||
patchFunc(st, (x) => ({ ...x, sheet }));
|
||
setTimeout(() => refreshSnap(st), 0);
|
||
}
|
||
|
||
/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */
|
||
function refreshSnap(st: State): void {
|
||
if (!st.handle) return;
|
||
st.snap = st.handle.recalc();
|
||
st.redraw();
|
||
}
|
||
|
||
async function mountSheet(st: State): Promise<void> {
|
||
const { createSpreadsheet } = await loadSheet();
|
||
const f = funcOf(st);
|
||
if (!st.parts.sheet.isConnected || st.handle || !f) return;
|
||
st.parts.sheet.replaceChildren();
|
||
st.json = sheetJson(f);
|
||
st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), {
|
||
preview: true,
|
||
tabs: false,
|
||
readOnly: st.readOnly,
|
||
onChange: (b) => onBook(st, b),
|
||
onSelect: (s) => {
|
||
st.cell = s.칸;
|
||
drawHead(st);
|
||
if (st.form) drawVars(st);
|
||
},
|
||
});
|
||
st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st)));
|
||
st.observer.observe(st.parts.sheet, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ["data-addr"],
|
||
});
|
||
st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault());
|
||
st.parts.sheet.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
const port = e.dataTransfer?.getData("text/plain");
|
||
const cell = (e.target as Element | null)?.closest<HTMLElement>(".aislo-cell")?.dataset.addr;
|
||
if (port && cell) placePort(st, port, cell);
|
||
});
|
||
refreshSnap(st);
|
||
}
|
||
|
||
/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
|
||
export function renderFuncPanel(
|
||
host: HTMLElement,
|
||
lg: CfLogic,
|
||
boxId: string,
|
||
on: Handlers,
|
||
opts: { readOnly?: boolean } = {},
|
||
): void {
|
||
const old = states.get(host);
|
||
const readOnly = !!opts.readOnly;
|
||
if (old && old.boxId === boxId && old.readOnly === readOnly) {
|
||
old.lg = lg;
|
||
old.on = on;
|
||
const f = funcOf(old);
|
||
if (old.handle && f && sheetJson(f) !== old.json) {
|
||
old.json = sheetJson(f);
|
||
old.handle.setDoc(bookOf(f));
|
||
setTimeout(() => refreshSnap(old), 0);
|
||
return;
|
||
}
|
||
return old.redraw();
|
||
}
|
||
if (old) {
|
||
old.observer?.disconnect();
|
||
old.handle?.destroy();
|
||
}
|
||
const parts: State["parts"] = {
|
||
head: el("div", { className: "m01-cfpanel-head" }),
|
||
got: el("div", { className: "m01-cfpanel-row" }),
|
||
vars: el("div", { className: "m01-cfpanel-vars" }),
|
||
bar: el("div", { className: "m01-cfpanel-bar" }),
|
||
msg: el("p", { className: "m01-cfpanel-msg", attrs: { role: "status" } }),
|
||
sheet: el("div", { className: "m01-cfpanel-sheet", text: tf("Loading") }),
|
||
};
|
||
const st: State = {
|
||
boxId,
|
||
lg,
|
||
on,
|
||
readOnly,
|
||
handle: null,
|
||
snap: {},
|
||
json: "",
|
||
cell: "A1",
|
||
form: null,
|
||
parts,
|
||
observer: null,
|
||
redraw: () => {
|
||
drawHead(st);
|
||
drawGot(st);
|
||
drawVars(st);
|
||
drawBar(st);
|
||
applyMarks(st);
|
||
},
|
||
};
|
||
states.set(host, st);
|
||
host.classList.add("m01-cfpanel");
|
||
host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet);
|
||
parts.msg.hidden = true;
|
||
st.redraw();
|
||
void mountSheet(st);
|
||
}
|