/* ============================================================================= * spreadsheet_condition.ts * 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF) * 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내 * 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로 * 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림 * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; import { toA1 } from "./spreadsheet_address"; import { COND_OPS, CELL, draftToFormula, formulaToDraft, type CondBranch, type CondDraft, type CondOp, type CondOperand, } from "./spreadsheet_condition_text"; import "./spreadsheet_condition.css"; /** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */ export interface ConditionOptions { valuesFor(a1: string): (string | number)[] | null; pills?(): { name: string; cell: string }[]; } export { COND_OPS, draftToFormula, formulaToDraft }; export type { CondBranch, CondDraft, CondOp, CondOperand }; /* ── 글 ─────────────────────────────────────────────────────────────────── */ const TEXT = { Menu: ["조건 만들기", "Make a condition"], Title: ["조건 만들기", "Make a condition"], If: ["만약", "If"], ElseIf: ["아니면 만약", "Else if"], Then: ["→", "→"], Otherwise: ["아니면 →", "Otherwise →"], AddElseIf: ["+ 아니면 만약", "+ Else if"], Remove: ["지움", "Remove"], Make: ["만들기", "Make"], Cancel: ["취소", "Cancel"], Close: ["닫기", "Close"], CellPick: ["칸 직접", "A cell"], ValuePick: ["값", "A value"], Cell: ["칸 주소", "Cell address"], Value: ["값", "Value"], BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], Target: ["식이 들어갈 칸", "Formula goes into"], AsIs: [ "이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침", "This formula can't be shown as lines — edit the formula as it is", ], } as const; const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; /* ── 줄 판 ───────────────────────────────────────────────────────────────── */ const OWN = "\u0001cell"; const OWN_VALUE = "\u0001value"; function select( options: { value: string; text: string }[], value: string, onPick: (v: string) => void, aria: string, ): HTMLSelectElement { const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } }); for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text })); s.value = value; s.addEventListener("change", () => onPick(s.value)); return s; } /** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */ function cellPick( opts: ConditionOptions, value: string, onChange: (cell: string) => void, aria: string, ): HTMLElement { const pills = opts.pills?.() ?? []; const hit = pills.find((p) => p.cell === value); const options = [ ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), { value: OWN, text: tc("CellPick") }, ]; const box = el("span", { className: "ss-cond-pick" }); const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria); box.append(sel); if (!hit) { const input = el("input", { className: "ui-input ui-input--sm ss-cond-cell", attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" }, }); input.value = value; input.addEventListener("change", () => { const m = CELL.exec(input.value.trim().toUpperCase()); input.classList.toggle("is-bad", !m); input.title = m ? "" : tc("BadCell"); if (m) onChange(`${m[1]}${m[2]}`); }); box.append(input); } return box; } /** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */ function operandPick( opts: ConditionOptions, o: CondOperand, hint: string | null, onChange: (o: CondOperand) => void, aria: string, ): HTMLElement { const box = el("span", { className: "ss-cond-pick" }); const pills = opts.pills?.() ?? []; const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell); const options = [ ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), { value: OWN, text: tc("CellPick") }, { value: OWN_VALUE, text: tc("ValuePick") }, ]; const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN; box.append( select( options, mode, (v) => { if (v === OWN_VALUE) onChange({ kind: "value", value: 0 }); else if (v === OWN) onChange({ kind: "cell", cell: "B2" }); else onChange({ kind: "cell", cell: v }); }, aria, ), ); if (o.kind === "cell" && !isPill) { const input = el("input", { className: "ui-input ui-input--sm ss-cond-cell", attrs: { type: "text", "aria-label": tc("Cell") }, }); input.value = o.cell; input.addEventListener("change", () => { const m = CELL.exec(input.value.trim().toUpperCase()); input.classList.toggle("is-bad", !m); if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` }); }); box.append(input); } if (o.kind === "value") { const list = hint ? opts.valuesFor(hint) : null; if (list && list.length) { const key = (v: string | number): string => `${typeof v}:${v}`; box.append( select( list.map((v) => ({ value: key(v), text: String(v) })), key(o.value), (k) => { const hitv = list.find((v) => key(v) === k); if (hitv !== undefined) onChange({ kind: "value", value: hitv }); }, tc("Value"), ), ); } else { const input = el("input", { className: "ui-input ui-input--sm ss-cond-value", attrs: { type: "text", "aria-label": tc("Value") }, }); input.value = String(o.value); input.addEventListener("change", () => { const t = input.value.trim(); onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t }); }); box.append(input); } } return box; } function openJob( ctx: SpreadsheetContext, opts: ConditionOptions, a1: string, draft: CondDraft | null, existing: string | null, ): void { openModal({ title: `${tc("Title")} — ${a1}`, closeLabel: tc("Close"), dialogClass: "ss-cond", mount: (body, close) => { let d: CondDraft = draft ?? { branches: [ { left: "B1", op: "=", right: { kind: "value", value: "" }, then: { kind: "value", value: 0 }, }, ], otherwise: { kind: "value", value: 0 }, }; if (existing !== null && !draft) { body.append( el("p", { className: "ss-cond-note", text: tc("AsIs") }), el("code", { className: "ss-cond-formula", text: `=${existing}` }), ); return; } const draw = (): void => { const set = (next: CondDraft): void => ((d = next), draw()); const rows = d.branches.map((b, i) => { const edit = (patch: Partial): void => set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); return el("div", { className: "ss-cond-row", children: [ el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }), cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")), select( COND_OPS.map((o) => ({ value: o, text: o })), b.op, (v) => edit({ op: v as CondOp }), "op", ), operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")), tc("Then"), operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")), d.branches.length > 1 ? createButton({ label: tc("Remove"), variant: "ghost", size: "sm", onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }), }) : "", ], }); }); const last = d.branches[d.branches.length - 1]; body.replaceChildren( ...rows, el("div", { className: "ss-cond-row", children: [ createButton({ label: tc("AddElseIf"), variant: "ghost", size: "sm", onClick: () => set({ ...d, branches: [ ...d.branches, { left: last.left, op: "=", right: { kind: "value", value: "" }, then: { kind: "value", value: 0 }, }, ], }), }), ], }), el("div", { className: "ss-cond-row", children: [ el("span", { className: "ss-cond-label", text: tc("Otherwise") }), operandPick( opts, d.otherwise, null, (otherwise) => set({ ...d, otherwise }), tc("Otherwise"), ), ], }), el("div", { className: "ss-cond-row ss-cond-foot", children: [ el("code", { className: "ss-cond-formula", text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`, }), createButton({ label: tc("Make"), variant: "filled", size: "sm", onClick: () => { const sheet = ctx.sheet(); const { 값: _v, ...keep } = sheet.칸[a1] ?? {}; ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [a1]: { ...keep, 식: draftToFormula(d) } }, }); close(); }, }), createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }), ], }), ); }; draw(); }, }); } /** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */ export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle { let menu: HTMLElement | null = null; const close = (): void => { menu?.remove(); menu = null; document.removeEventListener("pointerdown", away, true); }; const away = (e: Event): void => { if (menu && !menu.contains(e.target as Node)) close(); }; const onContext = (e: MouseEvent): void => { const hit = ctx.grid.hitTest(e.clientX, e.clientY); if (!hit || hit.kind !== "cell" || ctx.readOnly) return; e.preventDefault(); e.stopPropagation(); close(); const a1 = toA1(hit.r, hit.c); const sheet = ctx.sheet(); const cell = sheet.칸[a1]; menu = el("div", { className: "ss-cond-menu" }); menu.style.left = `${e.clientX}px`; menu.style.top = `${e.clientY}px`; menu.append( createButton({ label: tc("Menu"), variant: "ghost", size: "sm", onClick: () => { close(); const existing = cell?.식 ?? null; const draft = existing !== null ? formulaToDraft(existing) : null; openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null); }, }), ); document.body.append(menu); document.addEventListener("pointerdown", away, true); }; ctx.root.addEventListener("contextmenu", onContext, true); return { root: null, refresh() {}, destroy() { ctx.root.removeEventListener("contextmenu", onContext, true); close(); }, }; }