diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 5596915d8..4c7e91766 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -110,7 +110,7 @@ export function createSpreadsheet( ): SpreadsheetHandle { const readOnly = !!opts.readOnly; const noTabs = opts.tabs === false; - const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음 + const bare = readOnly || !!opts.preview; // 도구 모음 없음 · 우클릭은 「복사」 한 줄 const root = el("div", { className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`, }); @@ -142,7 +142,7 @@ export function createSpreadsheet( ctx.editor = ed; const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx); const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx); - const menu: PartHandle | null = bare ? null : attachMenu(ctx); + const menu: PartHandle = attachMenu(ctx); // 읽기 전용 · 미리보기는 「복사」 한 줄(81-3) const clip = attachClipboard(ctx); const status = createStatusBar(ctx); const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p); diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 603fa99e7..a2af5138f 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -12,10 +12,21 @@ * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { rangeToA1 } from "./spreadsheet_address"; import { confirmMerge, dialogKeys } from "./spreadsheet_actions"; import { deleteComment, getComment, setComment } from "./spreadsheet_comments"; +import { + cellInsertItems, + cellMoreItems, + colInsertItems, + colMoreItems, + copyCut, + copyOnlyItems, + pasteItems, + rowInsertItems, + rowMoreItems, +} from "./spreadsheet_menu_items"; import { st } from "./spreadsheet_text"; import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; @@ -28,7 +39,8 @@ function within(range: CellRange, r: number, c: number): boolean { /** `ctx.root` 의 contextmenu 를 받음(root null) */ export function attachMenu(ctx: SpreadsheetContext): PartHandle { - if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; + // 읽기 전용 · 미리보기 = 「복사」 한 줄(81-3) + const bare = ctx.readOnly || ctx.root.classList.contains("ss--preview"); const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); @@ -39,7 +51,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { } function cutCopy(kind: "cut" | "copy"): void { - document.execCommand(kind); + copyCut(ctx, kind); } /** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는 @@ -79,7 +91,10 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], + ...pasteItems(ctx), + "-", + ...cellInsertItems(ctx, range), + "-", [ st("MenuClearContent"), () => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }), @@ -107,6 +122,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { ...(note ? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[]) : []), + ...cellMoreItems(ctx, range), ...(ctx.cellMenuExtra?.(r, c) ?? []), ]; } @@ -117,11 +133,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], - [ - st("MenuInsertRow"), - () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }), - ], + ...pasteItems(ctx), + "-", + ...rowInsertItems(ctx, at, 수), [ st("MenuDeleteRow"), () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }), @@ -148,6 +162,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + ...rowMoreItems(ctx, rows), ]; } @@ -157,11 +172,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], - [ - st("MenuInsertCol"), - () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }), - ], + ...pasteItems(ctx), + "-", + ...colInsertItems(ctx, at, 수), [ st("MenuDeleteCol"), () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }), @@ -188,6 +201,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + ...colMoreItems(ctx, cols), ]; } @@ -217,7 +231,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 활성: { r: hit.r, c: 0 }, 기준: { r: hit.r, c: 0 }, }); - menu.open(x, y, rowMenuItems(rows)); + menu.open(x, y, bare ? copyOnlyItems(ctx) : rowMenuItems(rows)); return; } @@ -235,7 +249,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 활성: { r: 0, c: hit.c }, 기준: { r: 0, c: hit.c }, }); - menu.open(x, y, colMenuItems(cols)); + menu.open(x, y, bare ? copyOnlyItems(ctx) : colMenuItems(cols)); return; } @@ -250,7 +264,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { }); } const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c); - menu.open(x, y, cellMenuItems(active, hit.r, hit.c)); + menu.open( + x, + y, + bare + ? [...copyOnlyItems(ctx), ...(ctx.cellMenuExtra?.(hit.r, hit.c) ?? [])] + : cellMenuItems(active, hit.r, hit.c), + ); } } diff --git a/resources/tester/spreadsheet/test_stage4_menu_items.ts b/resources/tester/spreadsheet/test_stage4_menu_items.ts new file mode 100644 index 000000000..3c7fe1aec --- /dev/null +++ b/resources/tester/spreadsheet/test_stage4_menu_items.ts @@ -0,0 +1,304 @@ +/* ============================================================================= + * test_stage4_menu_items.ts — 우클릭 메뉴 덧붙인 항목(81-3) 시험. + * 행 위/아래 · 열 좌우 삽입 · 셀 삽입/삭제 · 행열 삭제 · 크기 · 시트 정렬 · 필터 · 고정 · 그룹 · + * 범위 이름 · 붙여넣기 보안 문맥 갈래 · 구분선 · 읽기 전용 복사. + * 돌리기: npx tsx resources/tester/spreadsheet/test_stage4_menu_items.ts + * ========================================================================== */ + +import { register } from "node:module"; +import type { MapContextMenuItem } from "../../../ui_template/ui_template_context_menu.ts"; +import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands.ts"; +import { createCalcEngine } from "../../../A00_Common/spreadsheet/spreadsheet_graph.ts"; +import { + canMenuPaste, + cellInsertItems, + cellMoreItems, + colInsertItems, + colMoreItems, + copyOnlyItems, + menuPaste, + nameRange, + pasteItems, + resizeLines, + rowInsertItems, + rowMoreItems, + sortByColumn, + usedRange, +} from "../../../A00_Common/spreadsheet/spreadsheet_menu_items.ts"; +import type { Cell, Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts"; +import type { + Selection, + SpreadsheetContext, +} from "../../../A00_Common/spreadsheet/spreadsheet_view_types.ts"; +import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; + +// 늦게 부르는 창 모듈의 css 는 node 에서 빈 모듈로 +register( + "data:text/javascript,export async function load(u,c,n){return u.endsWith('.css')?{format:'module',source:'',shortCircuit:true}:n(u,c)}", +); +const tick = () => new Promise((r) => setTimeout(r, 30)); + +/** 진짜 명령 · 엔진 · 고름을 쓰는 문맥 */ +function realCtx(cells: Record): SpreadsheetContext & { sent: Command[] } { + const book: Workbook = makeBook([makeSheet("s1", cells)]); + const base = makeCtx(book, "s1"); + const sent: Command[] = []; + const ctx = base as unknown as SpreadsheetContext & { sent: Command[] }; + ctx.engine = createCalcEngine(book); + ctx.sent = sent; + ctx.dispatch = (cmd: Command) => { + sent.push(cmd); + applyCommand(book, cmd); + ctx.engine.rebuild(book); + }; + ctx.select = (sel: Selection) => void (ctx.selection = sel); + return ctx; +} +const col = (n: number) => + Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(i, 0), v(i + 1)])); +const row = (n: number) => + Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(0, i), v(i + 1)])); +const val = (ctx: SpreadsheetContext, r: number, c: number) => ctx.sheet().칸[a1(r, c)]?.값; +const leaf = (items: MapContextMenuItem[], label: string): (() => void) => { + for (const it of items) { + if (it === "-") continue; + if (Array.isArray(it)) { + if (it[0] === label) return it[1]; + } else { + const hit = it.children.find(([l]) => l === label); + if (hit) return hit[1]; + } + } + throw new Error(`항목 없음: ${label}`); +}; +const labels = (items: MapContextMenuItem[]) => + items.map((it) => (it === "-" ? "-" : Array.isArray(it) ? it[0] : `${it.label} ▸`)); + +// ── 행 위 · 아래 삽입(고른 2행 B2:B3 → at 1 · 수 2) ───────────────────────── +{ + const ctx = realCtx(col(5)); + leaf(rowInsertItems(ctx, 1, 2), "위에 2행 삽입")(); + assertEqual( + [val(ctx, 0, 0), val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)], + [1, undefined, undefined, 2], + "위에 2행 — 2행부터 밀림", + ); +} +{ + const ctx = realCtx(col(5)); + leaf(rowInsertItems(ctx, 1, 2), "아래에 2행 삽입")(); + assertEqual( + [val(ctx, 2, 0), val(ctx, 3, 0), val(ctx, 4, 0), val(ctx, 5, 0)], + [3, undefined, undefined, 4], + "아래에 2행 — 고른 줄 뒤에 끼움", + ); +} +// ── 열 왼 · 오른쪽 삽입 ────────────────────────────────────────────────── +{ + const ctx = realCtx(row(4)); + leaf(colInsertItems(ctx, 1, 1), "왼쪽에 1열 삽입")(); + assertEqual([val(ctx, 0, 0), val(ctx, 0, 1), val(ctx, 0, 2)], [1, undefined, 2], "왼쪽에 1열"); +} +{ + const ctx = realCtx(row(4)); + leaf(colInsertItems(ctx, 1, 1), "오른쪽에 1열 삽입")(); + assertEqual([val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], [2, undefined, 3], "오른쪽에 1열"); +} +// ── 칸 메뉴 — 셀 삽입/삭제 ▸ · 행열 삭제 ▸ ────────────────────────────── +{ + const g = { r0: 0, c0: 1, r1: 0, c1: 1 }; + const ctx = realCtx(row(3)); + const items = cellInsertItems(ctx, g); + assertEqual( + labels(items), + ["행 · 열 삽입 ▸", "셀 삽입 ▸", "행 · 열 삭제 ▸", "셀 삭제 ▸"], + "칸 메뉴 하위 넷", + ); + leaf(items, "셀 삽입 후 오른쪽으로 밀기")(); + await tick(); + assertEqual( + [val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], + [undefined, 2, 3], + "셀 삽입 · 오른쪽으로", + ); + leaf(cellInsertItems(ctx, g), "셀 삭제 후 왼쪽으로 당기기")(); + await tick(); + assertEqual([val(ctx, 0, 1), val(ctx, 0, 2)], [2, 3], "셀 삭제 · 왼쪽으로"); +} +{ + const ctx = realCtx(col(3)); + leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삽입 후 아래로 밀기")(); + await tick(); + assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [undefined, 2], "셀 삽입 · 아래로"); + leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삭제 후 위로 당기기")(); + await tick(); + assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [2, 3], "셀 삭제 · 위로"); + leaf(cellInsertItems(ctx, { r0: 0, c0: 0, r1: 1, c1: 0 }), "2행 삭제")(); + assertEqual(val(ctx, 0, 0), 3, "칸 메뉴 행 삭제 · 고른 2행"); +} +// ── 크기 — px → 열폭(글자) · 행높이(pt) · 잘못된 값 ───────────────────────── +{ + const ctx = realCtx(row(2)); + assertEqual( + resizeLines(ctx, false, [0, 1], "abc"), + "1 이상 숫자를 넣을 것", + "크기 · 글이면 거름", + ); + assertEqual(resizeLines(ctx, false, [0, 1], "0"), "1 이상 숫자를 넣을 것", "크기 · 0 거름"); + assertEqual(resizeLines(ctx, false, [0, 1], "100"), null, "열 너비 100px"); + const w = ctx.sheet().열!; + assertEqual(w.A?.폭 === w.B?.폭 && (w.A?.폭 ?? 0) > 10, true, "열 둘 같은 폭(한 명령)"); + resizeLines(ctx, true, [2], "40"); + assertEqual( + Math.round((ctx.sheet().행?.["3"]?.높이 ?? 0) * 100) / 100, + 30, + "행 높이 40px = 30pt", + ); +} +// ── 시트 정렬(열 머리) — 쓴 범위 통째 · 고름 되돌림 · 빈 열은 안 함 ─────────────── +{ + const ctx = realCtx({ + A1: v("이름"), + B1: v("값"), + A2: v("다"), + B2: v(3), + A3: v("가"), + B3: v(1), + A4: v("나"), + B4: v(2), + }); + const keep = ctx.selection; + await sortByColumn(ctx, 1, false); + assertEqual( + [val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)], + ["가", "나", "다"], + "B 열 기준 오름 — A 열도 같이", + ); + assertEqual(val(ctx, 0, 0), "이름", "머리 행 그대로"); + assertEqual(ctx.selection, keep, "정렬 뒤 고름 되돌림"); + await sortByColumn(ctx, 1, true); + assertEqual([val(ctx, 1, 1), val(ctx, 3, 1)], [3, 1], "내림"); + const before = ctx.sent.length; + await sortByColumn(ctx, 5, false); + assertEqual(ctx.sent.length, before, "빈 열 정렬은 명령 없음"); + assertEqual(usedRange(ctx.sheet()), { r0: 0, c0: 0, r1: 3, c1: 1 }, "쓴 범위"); +} +// ── 필터 · 고정 · 그룹 · 더보기 ───────────────────────────────────────────── +{ + const ctx = realCtx({ A1: v("머리"), A2: v(1), B2: v(2) }); + const calls: [string, unknown][] = []; + ctx.dataMenu = (kind) => calls.push([kind, structuredClone(ctx.selection.범위)]); + const more = colMoreItems(ctx, [0]); + assertEqual( + labels(more), + [ + "-", + "열 크기 조절…", + "데이터에 맞추기", + "-", + "시트 정렬 A → Z", + "시트 정렬 Z → A", + "필터 만들기", + "-", + "고정 ▸", + "그룹 ▸", + ], + "열 머리 덧붙임 순서", + ); + leaf(more, "필터 만들기")(); + await tick(); + assertEqual(calls[0], ["filter", [{ r0: 0, c0: 0, r1: 1, c1: 1 }]], "열 머리 필터 = 쓴 범위로"); + leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "드롭다운…")(); + leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "조건부 서식…")(); + assertEqual( + calls.slice(1).map((c) => c[0]), + ["validation", "condfmt"], + "더보기 → 데이터 창", + ); + leaf(rowMoreItems(ctx, [0]), "첫 행 고정")(); + await tick(); + assertEqual(ctx.sent.at(-1)?.종류, "틀고정", "고정 ▸ 첫 행"); + ctx.selection = { + 시트: "s1", + 범위: [{ r0: 1, c0: 0, r1: 1, c1: 16383 }], + 활성: { r: 1, c: 0 }, + 기준: { r: 1, c: 0 }, + }; + leaf(rowMoreItems(ctx, [1]), "그룹으로 묶기")(); + await tick(); + assertEqual(ctx.sheet().행?.["2"]?.묶음, { 층: 1 }, "그룹 ▸ 묶기"); + assertEqual( + await nameRange(ctx, "", { r0: 0, c0: 0, r1: 0, c1: 0 }), + "이름을 넣을 것", + "범위 이름 · 빈 값", + ); + assertEqual( + await nameRange(ctx, "합계칸", { r0: 1, c0: 0, r1: 1, c1: 1 }), + null, + "범위 이름 지정", + ); + assertEqual( + typeof (await nameRange(ctx, "합계칸", { r0: 0, c0: 0, r1: 0, c1: 0 })), + "string", + "같은 이름은 거름", + ); +} +// ── 붙여넣기 — 보안 문맥 갈래 ─────────────────────────────────────────────── +{ + const g = globalThis as Record; + g.isSecureContext = false; + assertEqual(canMenuPaste(), false, "http(보안 문맥 아님) — 메뉴로 못 읽음 → Ctrl+V 안내"); + g.isSecureContext = true; + assertEqual(canMenuPaste(), false, "클립보드 API 없음 — 안내"); + const clip = { html: "", text: "7\t8" }; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + read: async () => [ + { + types: ["text/plain"], + getType: async () => new Blob([clip.text]), + }, + ], + }, + }); + assertEqual(canMenuPaste(), true, "localhost · https — 메뉴로 읽음"); + class FakeData { + data: Record = {}; + setData(k: string, s: string) { + this.data[k] = s; + } + } + class FakePaste { + constructor( + public type: string, + public init: { clipboardData: FakeData }, + ) {} + } + g.DataTransfer = FakeData; + g.ClipboardEvent = FakePaste; + const ctx = realCtx({}); + const events: FakePaste[] = []; + ctx.root = { dispatchEvent: (e: FakePaste) => events.push(e) } as unknown as HTMLElement; + await menuPaste(ctx); + assertEqual( + [events[0]?.type, events[0]?.init.clipboardData.data["text/plain"]], + ["paste", "7\t8"], + "보통 붙여넣기 = 부품 paste 길로", + ); + ctx.selection = { + 시트: "s1", + 범위: [{ r0: 2, c0: 0, r1: 2, c1: 0 }], + 활성: { r: 2, c: 0 }, + 기준: { r: 2, c: 0 }, + }; + await menuPaste(ctx, "값"); + assertEqual([val(ctx, 2, 0), val(ctx, 2, 1)], [7, 8], "값만 붙여넣기 — 활성 칸부터"); + assertEqual(labels(pasteItems(ctx)), ["붙여넣기", "선택하여 붙여넣기 ▸"], "붙여넣기 항목"); +} +// ── 읽기 전용 — 복사 한 줄 ───────────────────────────────────────────────── +{ + const ctx = realCtx({}); + assertEqual(labels(copyOnlyItems(ctx)), ["복사"], "읽기 전용 · 미리보기 = 복사 한 줄"); +} +console.log("spreadsheet_menu_items 시험 끝");