Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* 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<CondBranch>): 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();
|
|
},
|
|
};
|
|
}
|