/* ============================================================================= * spreadsheet_names.ts (3단계 · 주인 sub_laptop_4) * 이름 정의 — 범위에 이름을 붙이고 수식에서 `=단가*수량` 처럼 씀. 저장 자리 `Sheet.이름정의`(0 계약에 * 방금 더함) — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트 참조는 3단계 밖). * * 수식 나무는 이미 `{type:"name", name}` 갈래를 가짐(계약 `spreadsheet_types.ts` 3절 · 정의 없으면 * `#NAME?`). 이 파일은 이름을 저장 · 관리만 하고 실제로 식을 풀 때 이름을 찾는 일은 A 몫. * * 잇는 법(A · D · sub7 — 지금은 하나도 안 이어짐): * 1) `spreadsheet_eval.ts` 가 `{type:"name"}` 나무를 만나면 `resolveName(book, 식이든시트, 이름)` 을 * 불러 범위를 얻고, 범위가 칸 하나면 그 칸 값 · 여러 칸이면 `RangeValue` 로 바꿔 씀(없으면 지금처럼 `#NAME?`). * 2) 편집기 이름 상자(주소 상자)에서 범위를 고른 채 이름을 치고 Enter 하면 `defineName(ctx, 이름, 범위)`. * 3) 도구 모음 · 메뉴에 [이름 관리] 항목을 두고 `createNameManagerPanel(ctx)` 를 띄움. * ========================================================================== */ import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { rangeToA1 } from "./spreadsheet_address"; import type { CellRange, NamedRange, Sheet, Workbook } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; // css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함. export type { NamedRange }; const CELL_LIKE = /^[A-Za-z]{1,3}\d+$/; const RESERVED = /^(TRUE|FALSE)$/i; const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u; /** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */ export function invalidNameReason(name: string, existing: string[]): string | null { if (!name) return "이름이 비었음"; if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)"; if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀"; if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀"; if (existing.some((n) => n.toLowerCase() === name.toLowerCase())) return "이미 있는 이름"; return null; } export function listNames(book: Workbook): { 이름: string; 시트: string; 범위: CellRange }[] { return book.시트.flatMap((sheet) => (sheet.이름정의 ?? []).map((n) => ({ 이름: n.이름, 시트: sheet.id, 범위: n.범위 })), ); } /** 식이 든 시트의 이름부터 찾고(자기 시트 우선) · 없으면 다른 시트 이름도(느슨한 전역 대체). */ export function resolveName( book: Workbook, sheetId: string, name: string, ): { 시트: string; 범위: CellRange } | null { const lower = name.toLowerCase(); const own = book.시트.find((s) => s.id === sheetId); const local = own?.이름정의?.find((n) => n.이름.toLowerCase() === lower); if (local) return { 시트: sheetId, 범위: local.범위 }; for (const sheet of book.시트) { const found = sheet.이름정의?.find((n) => n.이름.toLowerCase() === lower); if (found) return { 시트: sheet.id, 범위: found.범위 }; } return null; } function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void { ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } }); } /** 지금 활성 시트에 이름을 더함 — 규칙 어기면 아무 일도 안 하고 까닭을 돌려줌(호출부가 토스트로). */ export function defineName(ctx: SpreadsheetContext, name: string, 범위: CellRange): string | null { const reason = invalidNameReason( name, listNames(ctx.book).map((n) => n.이름), ); if (reason) return reason; const sheet = ctx.sheet(); applySheet(ctx, sheet.id, { ...sheet, 이름정의: [...(sheet.이름정의 ?? []), { 이름: name, 범위 }], }); return null; } /** 이름을 지움 — 그 이름을 쓰던 식은 그대로 남아 다음 풀이 때 `#NAME?`(계약대로). */ export function deleteName(ctx: SpreadsheetContext, name: string): void { const lower = name.toLowerCase(); for (const sheet of ctx.book.시트) { if (!sheet.이름정의?.some((n) => n.이름.toLowerCase() === lower)) continue; applySheet(ctx, sheet.id, { ...sheet, 이름정의: sheet.이름정의!.filter((n) => n.이름.toLowerCase() !== lower), }); return; } } /** 이름을 바꿈 — 정의뿐 아니라 모든 시트 식 글 속 이름 글자도 같이 바꿈(시트이름 바꾸기와 같은 규칙). */ export function renameName( ctx: SpreadsheetContext, oldName: string, newName: string, ): string | null { const reason = invalidNameReason( newName, listNames(ctx.book) .map((n) => n.이름) .filter((n) => n.toLowerCase() !== oldName.toLowerCase()), ); if (reason) return reason; const commands = ctx.book.시트 .map((sheet) => renamedSheet(sheet, oldName, newName)) .filter((s): s is Sheet => s !== null) .map((sheet) => ({ 종류: "시트통째" as const, 시트: sheet })); if (!commands.length) return null; ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }); return null; } function renamedSheet(sheet: Sheet, oldName: string, newName: string): Sheet | null { // 한글 이름은 `\w` 밖이라 `\b` 가 안 먹음 — 글자 · 숫자 · 밑줄이 아닌 자리를 경계로 직접 잡음. const re = new RegExp(`(? n.이름.toLowerCase() === oldName.toLowerCase() ? { ...n, 이름: newName } : n, ); if (이름정의 && JSON.stringify(이름정의) !== JSON.stringify(sheet.이름정의)) changed = true; return changed ? { ...sheet, 칸, ...(이름정의 ? { 이름정의 } : {}) } : null; } function escapeReg(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** 이름 관리 창 — 통합문서 전체 이름 목록 + 고른 범위에 새 이름. */ export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle { const root = el("div", { className: "ss-names" }); const list = el("ul", { className: "ss-names__list" }); const nameInput = createInputField({ label: "새 이름", placeholder: "단가" }); const addBtn = createButton({ label: "이름 더하기" }); let error: HTMLElement | null = null; const showError = (msg: string | null): void => { error?.remove(); error = null; if (!msg) return; error = el("p", { text: msg }); error.style.color = "var(--color-danger, red)"; root.append(error); }; addBtn.addEventListener("click", () => { const 범위 = ctx.selection.범위[0]; if (!범위) return; const reason = defineName(ctx, nameInput.input.value.trim(), 범위); showError(reason); if (!reason) { nameInput.input.value = ""; render(); } }); function render(): void { list.replaceChildren( ...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => { const li = el("li", { className: "ss-names__row" }); const label = el("span", { text: `${이름} = ${시트}!${rangeToA1(범위)}` }); const rename = createButton({ label: "이름 바꾸기", variant: "ghost" }); rename.addEventListener("click", () => { const next = window.prompt("새 이름", 이름); if (!next || next === 이름) return; showError(renameName(ctx, 이름, next)); render(); }); const del = createButton({ label: "지움", variant: "danger" }); del.addEventListener("click", () => { deleteName(ctx, 이름); render(); }); li.append(label, rename, del); return li; }), ); } const form = el("div", { className: "ss-names__form", children: [nameInput.root, addBtn] }); root.append(el("h4", { text: "이름 관리" }), list, form); render(); return { root, refresh: render, destroy(): void { root.remove(); }, }; }