/* ============================================================================= * 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 { dialogKeys } from "./spreadsheet_actions"; import { parseA1, toA1 } from "./spreadsheet_address"; import { shiftSpan } from "./spreadsheet_refshift"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { Cell, CellLink, CellRange, Command, 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 }; } /** 밀기 · 당기기 범위 안 알약 연결도 같이 옮김(104-2) — `axis` 범위(행이면 열 범위 · 열이면 행 범위) 안 * 알약만 · `dir` +1 밀기(삽입) · -1 당기기(삭제, `amount` 폭만큼 지운 자리의 알약은 빠짐). 안 바뀌면 `undefined` * (되돌리기 짝에 빈 「시트통째」를 안 끼움). */ function shiftLinks( sheet: Sheet, range: CellRange, axis: "col" | "row", dir: 1 | -1, amount: number, ): CellLink[] | undefined { const list = sheet.연결; if (!list?.length) return undefined; const lo = axis === "col" ? range.r0 : range.c0; const hi = axis === "col" ? range.r1 : range.c1; const at = axis === "col" ? range.c0 : range.r0; let changed = false; const next = list.flatMap((link): CellLink[] => { const p = parseA1(link.칸); if (!p) return [link]; const along = axis === "col" ? p.r : p.c; const cross = axis === "col" ? p.c : p.r; if (along < lo || along > hi || cross < at) return [link]; // 범위 밖 · 밀리는 자리 앞 — 그대로 if (dir < 0 && cross < at + amount) return ((changed = true), []); // 지운 자리 — 빠짐 changed = true; const moved = cross + dir * amount; return [{ ...link, 칸: axis === "col" ? toA1(p.r, moved) : toA1(moved, p.c) }]; }); return changed ? next : undefined; } /** 밀기 · 당기기에 이름정의 범위도 따라감(114-2 · 보낸 범위 이름 등) — 이름 범위가 `axis` 범위(띠) 안에 * 통째로 들 때만 옮김(띠에 걸치기만 하면 그대로 · 엑셀과 같음) · 다 지운 자리면 빠짐. 안 바뀌면 `undefined`. */ function shiftNames( sheet: Sheet, range: CellRange, axis: "col" | "row", dir: 1 | -1, amount: number, ): Sheet["이름정의"] | undefined { const list = sheet.이름정의; if (!list?.length) return undefined; const col = axis === "col"; const [lo, hi] = col ? [range.r0, range.r1] : [range.c0, range.c1]; let changed = false; const next = list.flatMap((n) => { const g = n.범위; if ((col ? g.r0 : g.c0) < lo || (col ? g.r1 : g.c1) > hi) return [n]; const [a, b] = col ? [g.c0, g.c1] : [g.r0, g.r1]; const to = shiftSpan( a, b, dir > 0, col ? range.c0 : range.r0, amount, col ? MAX_COLS : MAX_ROWS, ); if (to && to[0] === a && to[1] === b) return [n]; changed = true; if (!to) return []; return [{ ...n, 범위: col ? { ...g, c0: to[0], c1: to[1] } : { ...g, r0: to[0], r1: to[1] } }]; }); return changed ? next : undefined; } /** 「칸」 명령(값만) 앞에 — 알약 · 이름이 움직였으면 「시트통째」(연결 · 이름정의만 바꿈)를 한 묶음으로 * (되돌리기 한 걸음) */ function dispatchShift( ctx: SpreadsheetContext, sheet: Sheet, range: CellRange, axis: "col" | "row", dir: 1 | -1, amount: number, cells: Record, ): void { const cellCmd: Command = { 종류: "칸", 시트: ctx.selection.시트, 칸: cells }; const linked = shiftLinks(sheet, range, axis, dir, amount); const named = shiftNames(sheet, range, axis, dir, amount); const next = { ...sheet, ...(linked ? { 연결: linked } : {}), ...(named ? { 이름정의: named } : {}), }; ctx.dispatch( linked || named ? { 종류: "묶음", 명령: [{ 종류: "시트통째", 시트: next }, cellCmd] } : cellCmd, ); } // ═════════════════════════════════════════════════════════════════════════════ // 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고) // ═════════════════════════════════════════════════════════════════════════════ 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 = {}; 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; } } dispatchShift(ctx, sheet, range, "col", 1, width, 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 = {}; 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; } } dispatchShift(ctx, sheet, range, "row", 1, height, 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 = {}; 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; } } dispatchShift(ctx, sheet, range, "col", -1, width, 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 = {}; 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; } } dispatchShift(ctx, sheet, range, "row", -1, height, 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); dialogKeys(overlay, close, () => (onOk(choice()), close())); } 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, ); }