220 lines
8.6 KiB
TypeScript
220 lines
8.6 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_validation.ts (3단계 · 주인 sub_laptop_4)
|
|
* 데이터 유효성 — 목록(드롭다운) · 수 범위. 어기면 막거나(칸에 못 들어감) 경고만(그래도 들어감).
|
|
* 저장 자리 `Sheet.유효성`(0 계약에 방금 더함). 규칙은 범위 하나에 하나만 먹임(엑셀과 같이 — 겹치면
|
|
* 배열 앞쪽이 이김).
|
|
*
|
|
* 잇는 법(D · sub7 — 지금은 하나도 안 이어짐):
|
|
* 1) 편집기(`spreadsheet_editor.ts`)가 칸 편집을 커밋하기 전에 `checkValue(rule, candidate)` 를
|
|
* 불러 `어기면 === "막음"` 이면 `dispatch` 를 안 부르고(입력 취소) 메시지를 토스트로 · `"경고"` 면
|
|
* 토스트만 띄우고 그대로 진행.
|
|
* 2) 칸 편집이 시작될 때(`editor.begin`) `findValidation(sheet, r, c)` 가 「목록」 규칙이면
|
|
* `createValidationDropdown(ctx, r, c, rule)` 을 칸 옆에 띄우고 고르면 그 값을 칸에 넣음.
|
|
* 3) 행 · 열 넣기 · 지우기 뒤 `remapValidationRanges` 를 그 시트 `유효성` 에 적용(조건부 서식과 같은
|
|
* 자리 · A 몫 · 지금 안 이어짐).
|
|
* 4) 도구 모음 · 메뉴에 [데이터 유효성] 항목을 두고 `createValidationPanel(ctx)` 를 띄움.
|
|
* ========================================================================== */
|
|
|
|
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
|
import { inRange, rangeToA1 } from "./spreadsheet_address";
|
|
import type {
|
|
CellInput,
|
|
CellRange,
|
|
DataValidationKind,
|
|
DataValidationRule,
|
|
Sheet,
|
|
} from "./spreadsheet_types";
|
|
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
|
|
|
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
|
|
|
|
export type { DataValidationKind, DataValidationRule };
|
|
|
|
/** 칸이 든 범위를 덮는 첫 규칙(배열 앞쪽 우선) — 없으면 undefined. */
|
|
export function findValidation(sheet: Sheet, r: number, c: number): DataValidationRule | undefined {
|
|
return (sheet.유효성 ?? []).find((rule) => rule.범위.some((range) => inRange(range, r, c)));
|
|
}
|
|
|
|
export interface ValidationCheck {
|
|
ok: boolean;
|
|
어기면?: "막음" | "경고";
|
|
메시지: string;
|
|
}
|
|
|
|
function defaultMessage(kind: DataValidationKind): string {
|
|
if (kind.종류 === "목록") return `목록 안 값만 됨(${kind.값들.join(", ")})`;
|
|
const 아래 = kind.아래 ?? -Infinity;
|
|
const 위 = kind.위 ?? Infinity;
|
|
return `${아래 === -Infinity ? "" : 아래}${위 === Infinity ? " 이상" : ` ~ ${위}`} 수만 됨`;
|
|
}
|
|
|
|
function matchesKind(kind: DataValidationKind, candidate: CellInput): boolean {
|
|
if (kind.종류 === "목록")
|
|
return typeof candidate !== "boolean" && kind.값들.includes(String(candidate));
|
|
if (typeof candidate !== "number") return false;
|
|
if (kind.아래 !== undefined && candidate < kind.아래) return false;
|
|
if (kind.위 !== undefined && candidate > kind.위) return false;
|
|
return true;
|
|
}
|
|
|
|
/** 후보 값이 규칙을 지키는지 — 어기면 `어기면`(막음 · 경고)과 안내 글을 돌려줌. */
|
|
export function checkValue(rule: DataValidationRule, candidate: CellInput): ValidationCheck {
|
|
if (matchesKind(rule.규칙, candidate)) return { ok: true, 메시지: "" };
|
|
return { ok: false, 어기면: rule.어기면, 메시지: rule.메시지 || defaultMessage(rule.규칙) };
|
|
}
|
|
|
|
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
|
|
export function remapValidationRanges(
|
|
rules: DataValidationRule[],
|
|
shift: (range: CellRange) => CellRange | null,
|
|
): DataValidationRule[] {
|
|
const out: DataValidationRule[] = [];
|
|
for (const rule of rules) {
|
|
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
|
|
if (moved.length) out.push({ ...rule, 범위: moved });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
|
|
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
|
}
|
|
|
|
let seq = 0;
|
|
const newId = (): string => `dv${Date.now().toString(36)}${seq++}`;
|
|
|
|
export function addValidationRule(
|
|
ctx: SpreadsheetContext,
|
|
rule: Omit<DataValidationRule, "id">,
|
|
): void {
|
|
const sheet = ctx.sheet();
|
|
applySheet(ctx, { ...sheet, 유효성: [...(sheet.유효성 ?? []), { ...rule, id: newId() }] });
|
|
}
|
|
|
|
export function removeValidationRule(ctx: SpreadsheetContext, id: string): void {
|
|
const sheet = ctx.sheet();
|
|
applySheet(ctx, { ...sheet, 유효성: (sheet.유효성 ?? []).filter((r) => r.id !== id) });
|
|
}
|
|
|
|
/** 목록 규칙일 때 칸 옆에 띄울 작은 드롭다운(고르면 `onPick` 이 불림 — 실제 `dispatch` 는 부른 쪽 몫). */
|
|
export function createValidationDropdown(
|
|
ctx: SpreadsheetContext,
|
|
r: number,
|
|
c: number,
|
|
rule: DataValidationRule,
|
|
onPick: (value: string) => void,
|
|
): HTMLElement | null {
|
|
if (rule.규칙.종류 !== "목록") return null;
|
|
const box = ctx.grid.cellBox(r, c);
|
|
const dropdown = el("div", { className: "ss-validation__dropdown" });
|
|
dropdown.style.left = `${box.x}px`;
|
|
dropdown.style.top = `${box.y + box.h}px`;
|
|
dropdown.style.minWidth = `${box.w}px`;
|
|
dropdown.replaceChildren(
|
|
...rule.규칙.값들.map((value) => {
|
|
const item = el("div", { className: "ss-validation__dropdown-item", text: value });
|
|
item.addEventListener("click", () => onPick(value));
|
|
return item;
|
|
}),
|
|
);
|
|
return dropdown;
|
|
}
|
|
|
|
const kindText = (k: DataValidationKind): string =>
|
|
k.종류 === "목록" ? `목록: ${k.값들.join(", ")}` : `수범위: ${k.아래 ?? "−∞"} ~ ${k.위 ?? "+∞"}`;
|
|
|
|
/** 유효성 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. */
|
|
export function createValidationPanel(ctx: SpreadsheetContext): PartHandle {
|
|
const root = el("div", { className: "ss-validation" });
|
|
const list = el("ul", { className: "ss-validation__list" });
|
|
|
|
const kindSelect = createSelectField({
|
|
label: "규칙",
|
|
options: [
|
|
{ value: "목록", text: "목록(드롭다운)" },
|
|
{ value: "수범위", text: "수 범위" },
|
|
],
|
|
});
|
|
const listValues = createInputField({ label: "목록 값(쉼표로 나눔)" });
|
|
const min = createInputField({ label: "아래(수범위)" });
|
|
const max = createInputField({ label: "위(수범위)" });
|
|
const onBreak = createSelectField({
|
|
label: "어기면",
|
|
options: [
|
|
{ value: "막음", text: "막음" },
|
|
{ value: "경고", text: "경고" },
|
|
],
|
|
});
|
|
const message = createInputField({ label: "안내 글(생략 가능)" });
|
|
const addBtn = createButton({ label: "규칙 더하기" });
|
|
|
|
addBtn.addEventListener("click", () => {
|
|
const 범위 = ctx.selection.범위;
|
|
if (!범위.length) return;
|
|
const 규칙 = build규칙();
|
|
if (!규칙) return;
|
|
addValidationRule(ctx, {
|
|
범위: [...범위],
|
|
규칙,
|
|
어기면: onBreak.select.value as "막음" | "경고",
|
|
메시지: message.input.value || undefined,
|
|
});
|
|
render();
|
|
});
|
|
|
|
function build규칙(): DataValidationKind | null {
|
|
if (kindSelect.select.value === "목록") {
|
|
const 값들 = listValues.input.value
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
return 값들.length ? { 종류: "목록", 값들 } : null;
|
|
}
|
|
const 아래 = min.input.value ? Number(min.input.value) : undefined;
|
|
const 위 = max.input.value ? Number(max.input.value) : undefined;
|
|
return 아래 === undefined && 위 === undefined ? null : { 종류: "수범위", 아래, 위 };
|
|
}
|
|
|
|
function render(): void {
|
|
const rules = ctx.sheet().유효성 ?? [];
|
|
list.replaceChildren(
|
|
...rules.map((rule) => {
|
|
const li = el("li", { className: "ss-validation__row" });
|
|
const label = el("span", {
|
|
text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`,
|
|
});
|
|
const del = createButton({ label: "지움", variant: "danger" });
|
|
del.addEventListener("click", () => {
|
|
removeValidationRule(ctx, rule.id);
|
|
render();
|
|
});
|
|
li.append(label, del);
|
|
return li;
|
|
}),
|
|
);
|
|
}
|
|
|
|
const form = el("div", {
|
|
className: "ss-validation__form",
|
|
children: [
|
|
kindSelect.root,
|
|
listValues.root,
|
|
min.root,
|
|
max.root,
|
|
onBreak.root,
|
|
message.root,
|
|
addBtn,
|
|
],
|
|
});
|
|
root.append(el("h4", { text: "데이터 유효성 규칙" }), list, form);
|
|
render();
|
|
|
|
return {
|
|
root,
|
|
refresh: render,
|
|
destroy(): void {
|
|
root.remove();
|
|
},
|
|
};
|
|
}
|