Files
Aislo/A00_Common/spreadsheet/spreadsheet_data_tools.ts
T
eomsangdonandClaude Sonnet 5 2d70be32ea fix(spreadsheet): 어두운 테마에서 글자가 검정으로 남던 메뉴 · 팝업을 테마 토큰으로
브레인 지시로 M02 면적산출(SA01010100) · 재료산출(SM01010100) 화면의 메뉴 · 팝업을 ORCA eval 로
전수로 열어 글자색 대 실제 바탕색 대비를 잼(투명이면 조상으로 올라가 겹침 · 4.5 미만 + 어두운
테마에서 흰 바탕 폼 칸 표시). 원인은 color 를 안 준 브라우저 기본 색(button · input · textarea)과
없는 토큰 · 검정 고정.
- spreadsheet.css: 서식 붓 단추(.ss-brush · 서식… 붓 ↑ ↓ 필터 Σ 데이터 ▾) · 찾기/바꾸기 입력 ·
  찾기 단추 · 메모 · 골라 붙여넣기 단추 · 목록 ▼ 단추에 color 를 줌 · 주소 상자(.ss-addr) ·
  수식 입력줄(.ss-formula)은 흰 바탕 · 검정 글이던 것을 투명 바탕 · 글 상속으로
- spreadsheet_links.css: 자재 찾기 입력 바탕 · 글 토큰
- spreadsheet_toolbar.css: 시트 탭 이름 바꾸기 입력 · select 의 option 바탕 · 글 토큰
- spreadsheet_sortfilter.css · spreadsheet_analysis.css · spreadsheet_links.css: 정의 안 된
  --color-surface-sunken(마우스 올림 바탕이 안 나오던 것)을 --color-mist-violet 로(색 줄만 ·
  주인 A · D 파일이라 최소로)
- spreadsheet_sortfilter.css: 정렬 창 select 의 option 바탕 · 글 토큰
- spreadsheet_format_dialog.ts: 글꼴 미리보기 글자색 · 테두리 미리보기 기본 선색이 검정 고정이던 것을
  「자동」(검정 · windowtext · auto)이면 테마 글자색으로(isAutoInk 재사용)
- spreadsheet_data_tools.ts: 텍스트 나누기 · 연속 채우기 창의 select 두 곳에 ss-toolbar__select 를 붙여
  흰 바탕 · 검정 글이던 것을 테마로

ORCA(IP http://100.72.32.27:5181 · 전환 애니메이션 끄고 잼) — 어두운 테마: 기본 화면 · 셀/행 머리/열 머리
우클릭 메뉴 · 데이터 메뉴와 하위 · 조건부 서식 · 유효성 · 이름 관리 · 정렬 · 텍스트 나누기 · 중복 제거 ·
연속 채우기 · 칸 넣기/지우기 · 이동 · 셀 서식 창 여섯 탭 · 찾기/바꾸기 · 수식 편집 + 함수 자동완성 ·
메모 창 · 단위 · 테두리 패널 · 자재 알약 찾기 · 자동 필터 값 목록 · 풍선 · 붙여넣기 선택 · 유효성 목록 ·
오류 ⚠ 메뉴 · 수식 계산 · 알림, SM · SA 두 시트 모두 대비 4.5 미만 · 흰 바탕 폼 칸 0 건(고치기 전에는
서식 붓 계열 7 개가 글 rgb(0,0,0) 대 바탕 rgb(37,31,56) 대비 1.33 등). 밝은 테마도 같은 목록으로
재서 새로 깨진 곳 없음(남은 것은 --color-text-muted 가 밝은 테마 바탕에서 3.08 인 기존 토큰 값).
css 8KB 한도: 가장 큰 spreadsheet_toolbar.css 7432 바이트.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
2026-09-28 19:42:25 +09:00

425 lines
16 KiB
TypeScript

/* =============================================================================
* 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<Exclude<DelimiterKind, "사용자">, 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<string, Cell | null> = {};
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<string>();
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<string, Cell | null> = {};
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<number>(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<string, Cell | null> = {};
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);
});
}