/* ============================================================================= * spreadsheet_menu.ts (주인 E) * 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) · * 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기 · 메모 삽입/편집/삭제. * `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김. * 메모는 `spreadsheet_comments.ts`(sub1 · 브레인 답 2026-09-27)의 `setComment` · `deleteComment` · * `getComment` 를 부름 — 안에서 「시트통째」 명령 · 되돌리기 짝까지 만듦. 글 입력 창은 이 파일이 * 직접 띄움(작은 오버레이 · `showConfirmDialog` 와 같은 결의 자체 모달 · native prompt 안 씀). * * 이음(D `spreadsheet.ts` 잇는 자리) — `attachMenu(ctx)` 한 번 부름(반환 `root` = 메뉴 껍데기 · * 안 붙여도 됨 · `ctx.root` 안에 스스로 넣음). `ctx.grid.hitTest` 가 준비된 뒤(C 격자 붙은 뒤) 불러야 함. * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { rangeToA1 } from "./spreadsheet_address"; import { confirmMerge, dialogKeys } from "./spreadsheet_actions"; import { deleteComment, getComment, setComment } from "./spreadsheet_comments"; import { st } from "./spreadsheet_text"; import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; import "./spreadsheet_toolbar.css"; function within(range: CellRange, r: number, c: number): boolean { return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1; } /** `ctx.root` 의 contextmenu 를 받음(root null) */ export function attachMenu(ctx: SpreadsheetContext): PartHandle { const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); // 읽기 전용 · 미리보기 시트 — 메뉴 없이 무반응 대신 [복사] 한 줄만(81-3) if (ctx.readOnly) { const onReadOnlyMenu = (ev: MouseEvent): void => { if (!ctx.grid.hitTest(ev.clientX, ev.clientY)) return; ev.preventDefault(); const box = ctx.root.getBoundingClientRect(); menu.open(ev.clientX - box.left, ev.clientY - box.top, [ [st("MenuCopyOnly"), () => document.execCommand("copy")], ]); }; ctx.root.addEventListener("contextmenu", onReadOnlyMenu); return { root: menu.element, refresh() {}, destroy() { ctx.root.removeEventListener("contextmenu", onReadOnlyMenu); menu.close(); menu.element.remove(); }, }; } const loadMenuItems = () => import("./spreadsheet_menu_items"); const fallbackPasteHint = (ok: boolean): void => { if (!ok) showToast(st("MenuPasteHint"), "info"); }; ctx.cellMenuExtra ??= () => []; // 칸 메뉴 있음 — 조건 만들기가 이 끝에 붙음 function isMerged(range: CellRange): boolean { return (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); } function cutCopy(kind: "cut" | "copy"): void { document.execCommand(kind); } /** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는 * 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */ function openCommentEditor(initial: string, onSave: (text: string) => void): void { const overlay = el("div", { className: "ss-comment-editor" }); const panel = el("div", { className: "ss-comment-editor__panel" }); const textarea = el("textarea", { className: "ss-comment-editor__input", }) as HTMLTextAreaElement; textarea.value = initial; const done = (): void => overlay.remove(); const actions = el("div", { className: "ss-comment-editor__actions", children: [ createButton({ label: "취소", variant: "ghost", onClick: done }), createButton({ label: "저장", variant: "filled", onClick: () => { onSave(textarea.value); done(); }, }), ], }); panel.append(textarea, actions); overlay.append(panel); document.body.append(overlay); dialogKeys(overlay, done); textarea.focus(); textarea.select(); } function cellMenuItems(range: CellRange, r: number, c: number): MapContextMenuItem[] { const note = getComment(ctx.sheet(), r, c); return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], [ st("MenuPaste"), () => void loadMenuItems().then((m) => m.pasteFromMenu(ctx).then(fallbackPasteHint)), ], { label: st("MenuPasteSpecial"), children: [ [ st("MenuPasteValues"), () => void loadMenuItems().then((m) => m.pasteSpecialFromMenu(ctx, "값").then(fallbackPasteHint), ), ], [ st("MenuPasteFormat"), () => void loadMenuItems().then((m) => m.pasteSpecialFromMenu(ctx, "서식").then(fallbackPasteHint), ), ], ], }, [st("MenuInsertCells"), () => void loadMenuItems().then((m) => m.insertCells(ctx))], [st("MenuDeleteCells"), () => void loadMenuItems().then((m) => m.deleteCells(ctx))], [ st("MenuDeleteRow"), () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at: r, 수: 1 }), ], [ st("MenuDeleteCol"), () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at: c, 수: 1 }), ], { divider: true }, [ st("MenuClearContent"), () => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }), ], [ st("MenuClearFormat"), () => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }), ], [ isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"), async () => { const merged = isMerged(range); if (!merged && !(await confirmMerge(ctx, range))) return; ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range, }); }, ], [ note ? st("MenuCommentEdit") : st("MenuCommentInsert"), () => openCommentEditor(note ?? "", (text) => setComment(ctx, r, c, text)), ], ...(note ? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[]) : []), { divider: true }, [st("MenuAutofit"), () => void loadMenuItems().then((m) => m.autofitCols(ctx, [c]))], { divider: true }, [st("MenuCondFmt"), () => void loadMenuItems().then((m) => m.openCondFmt(ctx))], [st("MenuValidation"), () => void loadMenuItems().then((m) => m.openValidation(ctx))], [st("MenuNames"), () => void loadMenuItems().then((m) => m.openNames(ctx))], { divider: true }, [st("MenuFreeze"), () => void loadMenuItems().then((m) => m.freezeAtActiveCell(ctx))], [st("MenuUnfreeze"), () => void loadMenuItems().then((m) => m.unfreezePanes(ctx))], ...(ctx.cellMenuExtra?.(r, c) ?? []), ]; } function rowMenuItems(rows: number[]): MapContextMenuItem[] { const at = rows[0]; // rows 는 늘 오름차순 연속(81-2③) — 전체 고름이면 104만 개라 Math.min(...rows) 는 스택 넘침 const 수 = rows.length; return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], [ st("MenuPaste"), () => void loadMenuItems().then((m) => m.pasteFromMenu(ctx).then(fallbackPasteHint)), ], [ st("MenuInsertRowAbove"), () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuInsertRowBelow"), () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at: rows[rows.length - 1] + 1, 수, }), ], [ st("MenuDeleteRow"), () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuHide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "행", 번호: rows, 숨김: true, }), ], [ st("MenuUnhide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "행", 번호: rows, 숨김: false, }), ], { divider: true }, [st("MenuRowSize"), () => void loadMenuItems().then((m) => m.openRowSizeDialog(ctx, rows))], { divider: true }, [st("MenuFilterToggle"), () => void loadMenuItems().then((m) => m.filterToggle(ctx))], [st("MenuFreeze"), () => void loadMenuItems().then((m) => m.freezeFirstRow(ctx))], [st("MenuUnfreeze"), () => void loadMenuItems().then((m) => m.unfreezePanes(ctx))], { label: st("MenuGroup"), children: [ [ st("MenuGroupOn"), () => void loadMenuItems().then((m) => m.groupAxis(ctx, "행", rows, true)), ], [ st("MenuGroupOff"), () => void loadMenuItems().then((m) => m.groupAxis(ctx, "행", rows, false)), ], ], }, ]; } function colMenuItems(cols: number[]): MapContextMenuItem[] { const at = cols[0]; // cols 도 늘 오름차순 연속 — 같은 까닭(81-2③) const 수 = cols.length; return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], [ st("MenuPaste"), () => void loadMenuItems().then((m) => m.pasteFromMenu(ctx).then(fallbackPasteHint)), ], [ st("MenuInsertColLeft"), () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuInsertColRight"), () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at: cols[cols.length - 1] + 1, 수, }), ], [ st("MenuDeleteCol"), () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuHide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "열", 번호: cols, 숨김: true, }), ], [ st("MenuUnhide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "열", 번호: cols, 숨김: false, }), ], { divider: true }, [st("MenuColSize"), () => void loadMenuItems().then((m) => m.openColSizeDialog(ctx, cols))], [st("MenuAutofit"), () => void loadMenuItems().then((m) => m.autofitCols(ctx, cols))], { divider: true }, [st("MenuSortAsc"), () => void loadMenuItems().then((m) => m.sortAsc(ctx))], [st("MenuSortDesc"), () => void loadMenuItems().then((m) => m.sortDesc(ctx))], [st("MenuFilterToggle"), () => void loadMenuItems().then((m) => m.filterToggle(ctx))], [st("MenuFreeze"), () => void loadMenuItems().then((m) => m.freezeFirstColumn(ctx))], [st("MenuUnfreeze"), () => void loadMenuItems().then((m) => m.unfreezePanes(ctx))], { label: st("MenuGroup"), children: [ [ st("MenuGroupOn"), () => void loadMenuItems().then((m) => m.groupAxis(ctx, "열", cols, true)), ], [ st("MenuGroupOff"), () => void loadMenuItems().then((m) => m.groupAxis(ctx, "열", cols, false)), ], ], }, { divider: true }, [st("MenuCondFmt"), () => void loadMenuItems().then((m) => m.openCondFmt(ctx))], [st("MenuValidation"), () => void loadMenuItems().then((m) => m.openValidation(ctx))], [st("MenuNames"), () => void loadMenuItems().then((m) => m.openNames(ctx))], ]; } function range(r0: number, c0: number, r1: number, c1: number): CellRange { return { r0, c0, r1, c1 }; } function onContextMenu(ev: MouseEvent): void { const hit = ctx.grid.hitTest(ev.clientX, ev.clientY); if (!hit) return; ev.preventDefault(); const box = ctx.root.getBoundingClientRect(); const x = ev.clientX - box.left; const y = ev.clientY - box.top; if (hit.kind === "rowHead") { const covered = ctx.selection.범위.find( (rg) => rg.c0 === 0 && rg.c1 >= MAX_COLS - 1 && within(rg, hit.r, 0), ); const rows = covered ? Array.from({ length: covered.r1 - covered.r0 + 1 }, (_, i) => covered.r0 + i) : [hit.r]; if (!covered) ctx.select({ 시트: ctx.selection.시트, 범위: [range(hit.r, 0, hit.r, MAX_COLS - 1)], 활성: { r: hit.r, c: 0 }, 기준: { r: hit.r, c: 0 }, }); menu.open(x, y, rowMenuItems(rows)); return; } if (hit.kind === "colHead") { const covered = ctx.selection.범위.find( (rg) => rg.r0 === 0 && rg.r1 >= MAX_ROWS - 1 && within(rg, 0, hit.c), ); const cols = covered ? Array.from({ length: covered.c1 - covered.c0 + 1 }, (_, i) => covered.c0 + i) : [hit.c]; if (!covered) ctx.select({ 시트: ctx.selection.시트, 범위: [range(0, hit.c, MAX_ROWS - 1, hit.c)], 활성: { r: 0, c: hit.c }, 기준: { r: 0, c: hit.c }, }); menu.open(x, y, colMenuItems(cols)); return; } if (hit.kind === "cell") { const covered = ctx.selection.범위.some((rg) => within(rg, hit.r, hit.c)); if (!covered) { ctx.select({ 시트: ctx.selection.시트, 범위: [range(hit.r, hit.c, hit.r, hit.c)], 활성: { r: hit.r, c: hit.c }, 기준: { r: hit.r, c: hit.c }, }); } const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c); menu.open(x, y, cellMenuItems(active, hit.r, hit.c)); } } ctx.root.addEventListener("contextmenu", onContextMenu); return { root: menu.element, refresh() {}, destroy() { ctx.root.removeEventListener("contextmenu", onContextMenu); menu.close(); menu.element.remove(); }, }; }