/* ============================================================================= * spreadsheet_data_tools.ts (2단계 · 주인 sub_laptop_1) * 데이터 도구 셋 — ① 텍스트 나누기(구분 기호 · 고정 폭 · 미리보기) ② 중복된 항목 제거 * (열 고르기 · 지운 수 알림) ③ 채우기 연속 데이터(단계 값 · 끝 값 · 행/열). * 문서를 바꾸는 길은 「칸」 명령 하나뿐 — 세 도구 모두 이것만 씀. * * 잇는 법(D · sub7) — 메뉴 [데이터] 아래 세 항목을 만들어 `openTextToColumnsDialog(ctx)` · * `openRemoveDuplicatesDialog(ctx)` · `openFillSeriesDialog(ctx)` 를 각각 부름 * (`spreadsheet_menu.ts` 우클릭 메뉴에도 [중복된 항목 제거]는 달아 둘 만함). * * 고정 폭 나누기는 실제 엑셀처럼 눈금자를 끌어 자르지 않고 자를 자리(문자 수)를 * 쉼표로 적는 입력 한 줄로 단순화함(미리보기로 바로 확인 가능 — 자리 표시만 다름). * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { fracToString } from "@ui/sheet/ui_template_sheet_frac"; import { colName, toA1 } from "./spreadsheet_address"; import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; const TEXT = { textToColumns: "텍스트 나누기", byDelimiter: "구분 기호로", byFixedWidth: "고정 폭으로", delimiters: "구분 기호", tab: "탭", semicolon: "세미콜론", comma: "쉼표", space: "공백", custom: "사용자 지정", breaks: "자를 자리(문자 수, 쉼표로 구분)", preview: "미리보기", run: "실행", cancel: "취소", removeDuplicates: "중복된 항목 제거", hasHeader: "머리글 포함", keyColumns: "비교할 열", removed: (n: number) => `중복 ${n}개 지움`, fillSeries: "연속 데이터 채우기", direction: "방향", down: "아래로(열)", right: "오른쪽으로(행)", step: "단계 값", stop: "끝 값(선택)", }; // ═════════════════════════════════════════════════════════════════════════════ // 공용 — 값 ↔ 글, 작은 대화상자 뼈대 // ═════════════════════════════════════════════════════════════════════════════ function scalarText(v: Scalar): string { if (v == null) return ""; if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; if (typeof v === "string") return v; if (typeof v === "object" && "error" in v) return v.error; return fracToString(v); } function cellInputFrom(text: string): CellInput { const n = Number(text); return text !== "" && !Number.isNaN(n) ? n : text; } function row(label: string, ...controls: HTMLElement[]): HTMLElement { const r = el("div", { children: [el("span", { text: label }), ...controls], }); r.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:6px;"; return r; } /** `.ui-confirm` 배경 위에 제목 · 본문 · [실행]/[취소] 를 올린 작은 대화상자. */ function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel = TEXT.run): void { const overlay = el("div", { className: "ui-confirm" }); const panel = el("div", { className: "ui-confirm__panel" }); panel.style.minWidth = "360px"; const close = (): void => overlay.remove(); overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); }); panel.append(el("h3", { text: title }), ...body); panel.append( el("div", { className: "ui-confirm__actions", children: [ createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), createButton({ label: runLabel, variant: "filled", onClick: () => { onRun(); close(); }, }), ], }), ); overlay.append(panel); document.body.append(overlay); } function readRange(ctx: SpreadsheetContext): CellRange | null { return ctx.selection.범위[0] ?? null; } // ═════════════════════════════════════════════════════════════════════════════ // 1. 텍스트 나누기 // ═════════════════════════════════════════════════════════════════════════════ export type DelimiterKind = "탭" | "세미콜론" | "쉼표" | "공백" | "사용자"; const DELIM_CHARS: Record, string> = { 탭: "\t", 세미콜론: ";", 쉼표: ",", 공백: " ", }; function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** 한 줄을 고른 구분 기호로 나눔 — 기호가 하나도 없으면 원문 그대로 한 칸. */ export function splitDelimited(line: string, kinds: DelimiterKind[], custom: string): string[] { const chars = kinds .map((k) => (k === "사용자" ? custom : DELIM_CHARS[k])) .filter((c): c is string => !!c); if (chars.length === 0) return [line]; return line.split(new RegExp(chars.map(escapeRegExp).join("|"))); } /** 한 줄을 자를 자리(문자 수, 오름차순)로 나눔. */ export function splitFixedWidth(line: string, breaks: number[]): string[] { const points = [0, ...breaks.filter((b) => b > 0 && b < line.length), line.length]; const sorted = [...new Set(points)].sort((a, b) => a - b); const out: string[] = []; for (let i = 0; i < sorted.length - 1; i++) out.push(line.slice(sorted[i], sorted[i + 1])); return out; } /** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 칸 지움). */ export function applyTextToColumns( ctx: SpreadsheetContext, anchor: CellAddress, rows: string[][], ): void { const cells: Record = {}; rows.forEach((cols, ri) => { cols.forEach((text, ci) => { cells[toA1(anchor.r + ri, anchor.c + ci)] = text === "" ? null : { 값: cellInputFrom(text) }; }); }); ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); } export function openTextToColumnsDialog(ctx: SpreadsheetContext): void { if (ctx.readOnly) return; const range = readRange(ctx); if (!range) return; const lines: string[] = []; for (let r = range.r0; r <= range.r1; r++) { lines.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0))); } let mode: "구분" | "고정" = "구분"; let kinds: DelimiterKind[] = ["쉼표"]; let custom = ""; let breaksText = ""; const modeSelect = el("select", { className: "ss-toolbar__select", children: [ el("option", { attrs: { value: "구분" }, text: TEXT.byDelimiter }), el("option", { attrs: { value: "고정" }, text: TEXT.byFixedWidth }), ], }) as HTMLSelectElement; const delimBox = el("div"); const kindChecks = (["탭", "세미콜론", "쉼표", "공백", "사용자"] as DelimiterKind[]).map((k) => { const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; check.checked = kinds.includes(k); check.addEventListener("change", () => { kinds = check.checked ? [...kinds, k] : kinds.filter((x) => x !== k); refreshPreview(); }); return row( k === "탭" ? TEXT.tab : k === "세미콜론" ? TEXT.semicolon : k === "쉼표" ? TEXT.comma : k === "공백" ? TEXT.space : TEXT.custom, check, ); }); const customInput = createInputField({ label: TEXT.custom, onInput: (v) => { custom = v; refreshPreview(); }, }); delimBox.append(...kindChecks, customInput.root); const breaksField = createInputField({ label: TEXT.breaks, placeholder: "5,10,20", onInput: (v) => { breaksText = v; refreshPreview(); }, }); breaksField.root.hidden = true; const previewBox = el("pre"); previewBox.style.cssText = "max-height:160px;overflow:auto;background:var(--color-canvas);padding:6px;border-radius:4px;font-size:0.8rem;"; function splitAll(): string[][] { if (mode === "고정") { const breaks = breaksText .split(",") .map((s) => Number(s.trim())) .filter((n) => Number.isFinite(n)); return lines.map((l) => splitFixedWidth(l, breaks)); } return lines.map((l) => splitDelimited(l, kinds, custom)); } function refreshPreview(): void { previewBox.textContent = splitAll() .slice(0, 8) .map((cols) => cols.join(" | ")) .join("\n"); } modeSelect.addEventListener("change", () => { mode = modeSelect.value as "구분" | "고정"; delimBox.hidden = mode !== "구분"; breaksField.root.hidden = mode !== "고정"; refreshPreview(); }); refreshPreview(); modal( TEXT.textToColumns, [ row(TEXT.byDelimiter + "/" + TEXT.byFixedWidth, modeSelect), delimBox, breaksField.root, el("div", { text: TEXT.preview }), previewBox, ], () => applyTextToColumns(ctx, { r: range.r0, c: range.c0 }, splitAll()), ); } // ═════════════════════════════════════════════════════════════════════════════ // 2. 중복된 항목 제거 // ═════════════════════════════════════════════════════════════════════════════ /** `keyCols`(범위 안 0 부터 상대 열)로 값이 같은 뒤쪽 행들의 자리(0 부터 상대 행)를 돌려줌. */ export function findDuplicateRowIndexes(rows: string[][], keyCols: number[]): number[] { const seen = new Set(); const dup: number[] = []; rows.forEach((r, i) => { const key = keyCols.map((c) => r[c] ?? "").join("\u0001"); if (seen.has(key)) dup.push(i); else seen.add(key); }); return dup; } /** 범위 안 값을 읽어 중복 행을 지우고 위로 당김(범위 밖 칸은 안 건드림) — 지운 행 수를 돌려줌. */ export function removeDuplicateRows( ctx: SpreadsheetContext, range: CellRange, keyCols: number[], skipHeader: boolean, ): number { const width = range.c1 - range.c0 + 1; const bodyR0 = range.r0 + (skipHeader ? 1 : 0); const rows: string[][] = []; for (let r = bodyR0; r <= range.r1; r++) { const line: string[] = []; for (let c = 0; c < width; c++) line.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0 + c))); rows.push(line); } const dupIdx = new Set(findDuplicateRowIndexes(rows, keyCols)); const kept = rows.filter((_, i) => !dupIdx.has(i)); const cells: Record = {}; for (let i = 0; i < rows.length; i++) { const r = bodyR0 + i; for (let c = 0; c < width; c++) { const text = i < kept.length ? kept[i][c] : ""; cells[toA1(r, range.c0 + c)] = text === "" ? null : { 값: cellInputFrom(text) }; } } ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); return dupIdx.size; } export function openRemoveDuplicatesDialog(ctx: SpreadsheetContext): void { if (ctx.readOnly) return; const range = readRange(ctx); if (!range) return; const width = range.c1 - range.c0 + 1; let hasHeader = width > 1 || range.r1 > range.r0; const headerCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; headerCheck.checked = hasHeader; const keyCols = new Set(Array.from({ length: width }, (_, i) => i)); const colChecks = Array.from({ length: width }, (_, i) => { const c = range.c0 + i; const label = hasHeader ? scalarText(ctx.engine.value(ctx.selection.시트, range.r0, c)) || colName(c) : colName(c); const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; check.checked = true; check.addEventListener("change", () => { if (check.checked) keyCols.add(i); else keyCols.delete(i); }); return row(label, check); }); headerCheck.addEventListener("change", () => { hasHeader = headerCheck.checked; }); modal( TEXT.removeDuplicates, [row(TEXT.hasHeader, headerCheck), el("div", { text: TEXT.keyColumns }), ...colChecks], () => { const removed = removeDuplicateRows(ctx, range, [...keyCols], hasHeader); showToast(TEXT.removed(removed), "success"); }, ); } // ═════════════════════════════════════════════════════════════════════════════ // 3. 채우기 연속 데이터 // ═════════════════════════════════════════════════════════════════════════════ export type SeriesDirection = "행" | "열"; /** 시작값 · 단계 · 끝값(넘으면 멈춤, 없으면 count 까지) · 개수로 등차수열을 지음. */ export function buildLinearSeries( start: number, step: number, stop: number | null, count: number, ): number[] { const out: number[] = []; for (let i = 0; i < count; i++) { const v = start + step * i; if (stop != null && ((step >= 0 && v > stop) || (step < 0 && v < stop))) break; out.push(v); } return out; } export function applyFillSeries( ctx: SpreadsheetContext, anchor: CellAddress, direction: SeriesDirection, values: number[], ): void { const cells: Record = {}; values.forEach((v, i) => { const r = direction === "열" ? anchor.r + i : anchor.r; const c = direction === "행" ? anchor.c + i : anchor.c; cells[toA1(r, c)] = { 값: v }; }); ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); } export function openFillSeriesDialog(ctx: SpreadsheetContext): void { if (ctx.readOnly) return; const range = readRange(ctx); if (!range) return; const at = ctx.selection.활성; const startText = scalarText(ctx.engine.value(ctx.selection.시트, at.r, at.c)); const start = Number(startText) || 0; let direction: SeriesDirection = range.r1 > range.r0 ? "열" : "행"; let step = 1; let stop: number | null = null; const dirSelect = el("select", { className: "ss-toolbar__select", children: [ el("option", { attrs: { value: "열" }, text: TEXT.down }), el("option", { attrs: { value: "행" }, text: TEXT.right }), ], }) as HTMLSelectElement; dirSelect.value = direction; dirSelect.addEventListener("change", () => (direction = dirSelect.value as SeriesDirection)); const stepField = createInputField({ label: TEXT.step, type: "number", value: "1", onInput: (v) => (step = Number(v) || 0), }); const stopField = createInputField({ label: TEXT.stop, type: "number", onInput: (v) => (stop = v === "" ? null : Number(v)), }); modal(TEXT.fillSeries, [row(TEXT.direction, dirSelect), stepField.root, stopField.root], () => { const extent = direction === "열" ? range.r1 - range.r0 + 1 : range.c1 - range.c0 + 1; const values = buildLinearSeries(start, step, stop, extent); applyFillSeries(ctx, at, direction, values); }); }