M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 · 이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 · 수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 · 취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠. - 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이 빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림 - 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 · 테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글 대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91) - 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge) - 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때 초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 · Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이 - 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약 우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리 메뉴에 잘라내기 · 붙여넣기(안내) 더함 - 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면 위로 뒤집음 - Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다 바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells) - 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음 - 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신 시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침 - 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임 - 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지 - 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 → 화면 안쪽 칸에서만(안내) ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 · 어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum (여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry 197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은 저장 안 하고 「다시 열기」 로 버림. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
434 lines
16 KiB
TypeScript
434 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 { dialogKeys } from "./spreadsheet_actions";
|
|
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);
|
|
dialogKeys(overlay, close, () => (onRun(), close()));
|
|
}
|
|
|
|
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) => {
|
|
const key = toA1(anchor.r + ri, anchor.c + ci);
|
|
const 서식 = ctx.sheet().칸[key]?.서식;
|
|
if (text !== "")
|
|
cells[key] =
|
|
서식 === undefined ? { 값: cellInputFrom(text) } : { 값: cellInputFrom(text), 서식 };
|
|
else cells[key] = 서식 === undefined ? null : { 서식 };
|
|
});
|
|
});
|
|
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;
|
|
const key = toA1(r, c);
|
|
const 서식 = ctx.sheet().칸[key]?.서식;
|
|
cells[key] = 서식 === undefined ? { 값: v } : { 값: 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);
|
|
});
|
|
}
|