Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_8

This commit is contained in:
2026-09-27 21:29:55 +09:00
13 changed files with 1240 additions and 20 deletions
+13 -5
View File
@@ -292,16 +292,24 @@ function insertDelete(
setComments(s, notes);
}
const merges: CellRange[] = [];
for (const m of mergesOf(s)) {
const shiftRange = (m: CellRange) => {
const sp = rows
? shiftSpan(m.r0, m.r1, insert, at, n, max)
: shiftSpan(m.c0, m.c1, insert, at, n, max);
if (!sp) continue;
const next = rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] };
if (next.r0 !== next.r1 || next.c0 !== next.c1) merges.push(next);
return sp && (rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] });
};
const merges: CellRange[] = [];
for (const m of mergesOf(s)) {
const next = shiftRange(m);
if (next && (next.r0 !== next.r1 || next.c0 !== next.c1)) merges.push(next);
}
setMerges(s, merges);
// 이름 범위도 따라감 · 범위가 통째로 지워진 이름은 뺌(엑셀은 `#REF!` 이름으로 남김 — 여기선 식이 `#NAME?`)
if (s.이름정의)
s.이름정의 = s.이름정의.flatMap((d) => {
const 범위 = shiftRange(d.범위);
return 범위 ? [{ ...d, 범위 }] : [];
});
const axis = rows ? "행" : "열";
const table = s[axis];
@@ -0,0 +1,422 @@
/* =============================================================================
* 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", {
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", {
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);
});
}
@@ -15,7 +15,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { isError, isRange, numberText } from "./spreadsheet_eval";
import type { PlacedRange } from "./spreadsheet_eval";
import { evalSafe } from "./spreadsheet_graph";
import { bindNames, evalSafe } from "./spreadsheet_graph";
import { formulaToText, parseFormula } from "./spreadsheet_parser";
import type {
CalcEngine,
@@ -157,7 +157,7 @@ export function evalSteps(
for (const n of nodes) {
if (n === p.node) vals.set(n, engine.value(sheet, r, c));
else {
const v = evalSafe(n, ctx);
const v = evalSafe(bindNames(n, book, sheet), ctx);
if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로
vals.set(n, v);
}
+43 -6
View File
@@ -21,6 +21,7 @@ import type {
FormulaNode,
ParseResult,
Scalar,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
@@ -82,7 +83,36 @@ function collectRefs(
}
}
/** 나무 하나 풂 — 던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */
/** 이름(`값들`) → 정의한 범위 참조(자기 시트 이름 먼저 · 없으면 다른 시트) · 없는 이름은 그대로(→ `#NAME?`) */
export function bindNames(node: FormulaNode, book: Workbook, host: string): FormulaNode {
const go = (n: FormulaNode): FormulaNode => {
switch (n.type) {
case "name": {
const low = n.name.toLowerCase();
const def = (s?: Sheet) => s?.이름정의?.find((d) => d.이름.toLowerCase() === low);
const s = [book.시트.find((x) => x.id === host), ...book.시트].find(def);
if (!s) return n;
const g = def(s)!.범위;
const sheet = s.id === host ? null : s.이름;
const from = { r: g.r0, c: g.c0, absR: true, absC: true };
const to = { r: g.r1, c: g.c1, absR: true, absC: true };
return g.r0 === g.r1 && g.c0 === g.c1
? { type: "cell", sheet, ref: from }
: { type: "range", sheet, from, to };
}
case "unary":
return { ...n, arg: go(n.arg) };
case "binary":
return { ...n, left: go(n.left), right: go(n.right) };
case "call":
return { ...n, args: n.args.map(go) };
}
return n;
};
return book.시트.some((s) => s.이름정의?.length) ? go(node) : node;
}
/** 나무 하나 풂 —던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */
export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult {
try {
return evaluate(node, ctx);
@@ -111,9 +141,15 @@ export function createCalcEngine(book: Workbook): CalcEngine {
const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null;
const parse = (text: string) => {
let p = parsed.get(text);
if (!p) parsed.set(text, (p = parseFormula(text)));
/** 식 나무(이름은 범위로 바꿈) — 시트마다 캐시 · rebuild 때 비움 */
const parse = (text: string, host: string) => {
const k = `${host}!${text}`;
let p = parsed.get(k);
if (!p) {
p = parseFormula(text);
if (p.ok) p.node = bindNames(p.node, book, host);
parsed.set(k, p);
}
return p;
};
@@ -145,7 +181,7 @@ export function createCalcEngine(book: Workbook): CalcEngine {
vals.set(key, inputValue(cell.값!));
return;
}
const p = parse(cell.식);
const p = parse(cell.식, sheet.id);
const deps: Dep[] = [];
if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps);
precs.set(key, deps);
@@ -167,6 +203,7 @@ export function createCalcEngine(book: Workbook): CalcEngine {
function rebuild(next: Workbook) {
book = next;
parsed.clear();
sheets = new Map();
names = new Map();
precs = new Map();
@@ -294,7 +331,7 @@ export function createCalcEngine(book: Workbook): CalcEngine {
}
function run(text: string, ctx: EvalContext): Scalar {
const p = parse(text);
const p = parse(text, ctx.sheet);
if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`);
const one = intersect(evalSafe(p.node, ctx), ctx);
return one === null ? ZERO : one;
@@ -0,0 +1,240 @@
/* =============================================================================
* spreadsheet_insert_dialog.ts (2단계 · 주인 sub_laptop_1)
* 칸 삽입(Ctrl+Shift++) · 칸 삭제(Ctrl+-) 대화상자 — 엑셀처럼 네 갈래:
* 삽입 = 셀을 오른쪽으로 밀기 · 셀을 아래로 밀기 · 행 전체 · 열 전체
* 삭제 = 셀을 왼쪽으로 밀기 · 셀을 위로 밀기 · 행 전체 · 열 전체
* 「행 전체」·「열 전체」는 A 의 행넣기 · 행지우기 · 열넣기 · 열지우기 명령을 그대로 씀
* (참조 옮김까지 A 가 다 함). 「오른쪽 · 아래로 · 왼쪽 · 위로 밀기」는 0 계약에 전용 명령이
* 없어(부분 범위 이동 명령 없음) 「칸」 명령으로 손수 값 · 식 글 · 서식 번호를 옮김 —
* ⚠ 식의 상대 참조는 안 고쳐짐 · 행 · 열 단위 기본서식(`행`·`열` 레코드)도 안 옮김
* (부분 이동은 칸 레코드만). 온전한 참조 옮김이 필요하면 「행 전체」·「열 전체」를 쓰거나
* A 의 `spreadsheet_refshift.ts` 가 부분 범위까지 다루게 된 뒤 이 파일의 shift* 함수만 고치면 됨.
*
* 잇는 법(D · sub7) — Ctrl+Shift+= → `openInsertCellsDialog(ctx)` · Ctrl+- →
* `openDeleteCellsDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함. 우클릭 메뉴
* [삽입]·[삭제] 도 같은 함수를 부르면 됨.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { parseA1, toA1 } from "./spreadsheet_address";
import type { Cell, CellRange, Sheet } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
insertTitle: "삽입",
deleteTitle: "삭제",
shiftRight: "셀을 오른쪽으로 밀기",
shiftDown: "셀을 아래로 밀기",
shiftLeft: "셀을 왼쪽으로 밀기",
shiftUp: "셀을 위로 밀기",
wholeRow: "행 전체",
wholeCol: "열 전체",
ok: "확인",
cancel: "취소",
};
function readRange(ctx: SpreadsheetContext): CellRange | null {
return ctx.selection.범위[0] ?? null;
}
/** 시트 `칸` 레코드 안에서 실제 값이 있는 가장 큰 행 · 열(없으면 -1). */
function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } {
let maxR = -1;
let maxC = -1;
for (const key of Object.keys(sheet.칸)) {
const at = parseA1(key);
if (!at) continue;
if (at.r > maxR) maxR = at.r;
if (at.c > maxC) maxC = at.c;
}
return { maxR, maxC };
}
// ═════════════════════════════════════════════════════════════════════════════
// 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고)
// ═════════════════════════════════════════════════════════════════════════════
export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const width = range.c1 - range.c0 + 1;
const lastCol = Math.max(sheetUsedBounds(sheet).maxC + width, range.c1);
const cells: Record<string, Cell | null> = {};
for (let r = range.r0; r <= range.r1; r++) {
for (let c = lastCol; c >= range.c0; c--) {
const src = c - width;
cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const height = range.r1 - range.r0 + 1;
const lastRow = Math.max(sheetUsedBounds(sheet).maxR + height, range.r1);
const cells: Record<string, Cell | null> = {};
for (let c = range.c0; c <= range.c1; c++) {
for (let r = lastRow; r >= range.r0; r--) {
const src = r - height;
cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const width = range.c1 - range.c0 + 1;
const lastCol = Math.max(sheetUsedBounds(sheet).maxC, range.c1);
const cells: Record<string, Cell | null> = {};
for (let r = range.r0; r <= range.r1; r++) {
for (let c = range.c0; c <= lastCol; c++) {
const src = c + width;
cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const height = range.r1 - range.r0 + 1;
const lastRow = Math.max(sheetUsedBounds(sheet).maxR, range.r1);
const cells: Record<string, Cell | null> = {};
for (let c = range.c0; c <= range.c1; c++) {
for (let r = range.r0; r <= lastRow; r++) {
const src = r + height;
cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
// ═════════════════════════════════════════════════════════════════════════════
// 대화상자
// ═════════════════════════════════════════════════════════════════════════════
function radioGroup(
name: string,
options: [string, string][],
): { root: HTMLElement; value(): string } {
const inputs: HTMLInputElement[] = [];
const root = el("div", {
children: options.map(([value, label], i) => {
const input = el("input", {
attrs: { type: "radio", name, value, ...(i === 0 ? { checked: "true" } : {}) },
}) as HTMLInputElement;
inputs.push(input);
const wrap = el("label", { children: [input, el("span", { text: label })] });
wrap.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;";
return wrap;
}),
});
return { root, value: () => inputs.find((i) => i.checked)?.value ?? options[0][0] };
}
function modal(
title: string,
body: HTMLElement,
onOk: (choice: string) => void,
choice: () => string,
): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
panel.append(
el("h3", { text: title }),
body,
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({
label: TEXT.ok,
variant: "filled",
onClick: () => {
onOk(choice());
close();
},
}),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
}
export function openInsertCellsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const group = radioGroup("insertMode", [
["right", TEXT.shiftRight],
["down", TEXT.shiftDown],
["row", TEXT.wholeRow],
["col", TEXT.wholeCol],
]);
modal(
TEXT.insertTitle,
group.root,
(choice) => {
if (choice === "right") insertShiftRight(ctx, range);
else if (choice === "down") insertShiftDown(ctx, range);
else if (choice === "row") {
ctx.dispatch({
종류: "행넣기",
시트: ctx.selection.시트,
at: range.r0,
수: range.r1 - range.r0 + 1,
});
} else {
ctx.dispatch({
종류: "열넣기",
시트: ctx.selection.시트,
at: range.c0,
수: range.c1 - range.c0 + 1,
});
}
},
group.value,
);
}
export function openDeleteCellsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const group = radioGroup("deleteMode", [
["left", TEXT.shiftLeft],
["up", TEXT.shiftUp],
["row", TEXT.wholeRow],
["col", TEXT.wholeCol],
]);
modal(
TEXT.deleteTitle,
group.root,
(choice) => {
if (choice === "left") deleteShiftLeft(ctx, range);
else if (choice === "up") deleteShiftUp(ctx, range);
else if (choice === "row") {
ctx.dispatch({
종류: "행지우기",
시트: ctx.selection.시트,
at: range.r0,
수: range.r1 - range.r0 + 1,
});
} else {
ctx.dispatch({
종류: "열지우기",
시트: ctx.selection.시트,
at: range.c0,
수: range.c1 - range.c0 + 1,
});
}
},
group.value,
);
}
@@ -0,0 +1,197 @@
/* =============================================================================
* spreadsheet_view_menu.ts (2단계 · 주인 sub_laptop_1)
* 이동 대화상자(F5 · 주소 · 정의된 이름) · 틀 고정 메뉴(첫 행 · 첫 열 · 현재 칸 기준 · 풀기).
*
* 정의된 이름 목록은 `Sheet.이름정의`(3단계 · sub_laptop_4 계약 · 안 이어짐) 가 있으면
* 그대로 보여 줌 — 없으면 빈 목록(주소 입력만 됨). 이름으로 다른 시트를 못 가리키는
* 지금 계약대로 활성 시트 안에서만 찾음.
*
* 잇는 법(D · sub7) — F5 → `openGoToDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함.
* 틀 고정 단추(E 도구 모음)는 눌렀을 때 `openFreezePanesMenu(ctx, button)` 를 부르면 그
* 단추 아래 작은 메뉴가 뜸.
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { parseRange, rangeToA1 } from "./spreadsheet_address";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
goToTitle: "이동",
addressLabel: "참조",
namedRanges: "정의된 이름",
ok: "확인",
cancel: "취소",
notFound: "주소를 찾을 수 없음",
freezeFirstRow: "첫 행 고정",
freezeFirstColumn: "첫 열 고정",
freezeAtActive: "틀 고정(현재 칸 기준)",
unfreeze: "틀 고정 풀기",
};
// ═════════════════════════════════════════════════════════════════════════════
// 이동(F5)
// ═════════════════════════════════════════════════════════════════════════════
/** 범위(또는 칸) 글을 읽어 그 범위로 고름을 옮기고 보이게 스크롤함. */
export function goTo(ctx: SpreadsheetContext, target: CellRange): void {
const top = { r: target.r0, c: target.c0 };
ctx.select({ 시트: ctx.selection.시트, 범위: [target], 활성: top, 기준: top }, true);
}
export function openGoToDialog(ctx: SpreadsheetContext): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
const errorMsg = el("p", { text: "" });
errorMsg.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;";
const addressField = createInputField({
label: TEXT.addressLabel,
value: rangeToA1(
ctx.selection.범위[0] ?? {
r0: ctx.selection.활성.r,
c0: ctx.selection.활성.c,
r1: ctx.selection.활성.r,
c1: ctx.selection.활성.c,
},
),
});
const goToAddress = (): void => {
const target = parseRange(addressField.input.value);
if (!target) {
errorMsg.textContent = TEXT.notFound;
return;
}
goTo(ctx, target);
close();
};
addressField.input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") goToAddress();
});
const names = ctx.sheet().이름정의 ?? [];
const namesList = el("div", {
children: names.map((n) =>
createButton({
label: `${n.이름} (${rangeToA1(n.범위)})`,
variant: "ghost",
onClick: () => {
goTo(ctx, n.범위);
close();
},
}),
),
});
namesList.style.cssText =
"display:flex;flex-direction:column;gap:2px;max-height:160px;overflow:auto;";
panel.append(
el("h3", { text: TEXT.goToTitle }),
addressField.root,
errorMsg,
...(names.length > 0 ? [el("p", { text: TEXT.namedRanges }), namesList] : []),
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({ label: TEXT.ok, variant: "filled", onClick: goToAddress }),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
addressField.input.focus();
addressField.input.select();
}
// ═════════════════════════════════════════════════════════════════════════════
// 틀 고정
// ═════════════════════════════════════════════════════════════════════════════
export function freezeFirstRow(ctx: SpreadsheetContext): void {
ctx.dispatch({
종류: "틀고정",
시트: ctx.selection.시트,
행: 1,
열: ctx.sheet().틀고정?.열 ?? 0,
});
}
export function freezeFirstColumn(ctx: SpreadsheetContext): void {
ctx.dispatch({
종류: "틀고정",
시트: ctx.selection.시트,
행: ctx.sheet().틀고정?.행 ?? 0,
열: 1,
});
}
/** 활성 칸의 왼위 자리를 기준으로 그 위 · 왼쪽을 고정(활성 칸이 A1 이면 사실상 풀기와 같음). */
export function freezeAtActiveCell(ctx: SpreadsheetContext): void {
const at = ctx.selection.활성;
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: at.r, 열: at.c });
}
export function unfreezePanes(ctx: SpreadsheetContext): void {
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: 0, 열: 0 });
}
/** 도구 모음 단추 아래(또는 화면 가운데, `anchor` 없으면) 뜨는 작은 메뉴 — 누르면 바로 적용하고 닫힘. */
export function openFreezePanesMenu(ctx: SpreadsheetContext, anchor?: HTMLElement): void {
const menu = el("div", { className: "ui-confirm__panel" });
menu.style.cssText =
"position:fixed;display:flex;flex-direction:column;gap:2px;min-width:180px;z-index:1000;padding:6px;";
const items: [string, () => void][] = [
[TEXT.freezeFirstRow, () => freezeFirstRow(ctx)],
[TEXT.freezeFirstColumn, () => freezeFirstColumn(ctx)],
[TEXT.freezeAtActive, () => freezeAtActiveCell(ctx)],
[TEXT.unfreeze, () => unfreezePanes(ctx)],
];
const close = (): void => {
menu.remove();
document.removeEventListener("mousedown", onOutside, true);
document.removeEventListener("keydown", onEscape);
};
const onOutside = (ev: MouseEvent): void => {
if (!menu.contains(ev.target as Node)) close();
};
const onEscape = (ev: KeyboardEvent): void => {
if (ev.key === "Escape") close();
};
menu.append(
...items.map(([label, action]) =>
createButton({
label,
variant: "ghost",
onClick: () => {
action();
close();
},
}),
),
);
document.body.append(menu);
if (anchor) {
const box = anchor.getBoundingClientRect();
menu.style.top = `${box.bottom + 4}px`;
menu.style.left = `${box.left}px`;
} else {
menu.style.top = "40%";
menu.style.left = "50%";
menu.style.transform = "translate(-50%, -50%)";
}
setTimeout(() => {
document.addEventListener("mousedown", onOutside, true);
document.addEventListener("keydown", onEscape);
}, 0);
}