/* ============================================================================= * 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 { 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 { if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); 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); 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("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], [ st("MenuClearContent"), () => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }), ], [ st("MenuClearFormat"), () => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }), ], [ isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"), () => ctx.dispatch( isMerged(range) ? { 종류: "병합풀기", 시트: ctx.selection.시트, 범위: range } : { 종류: "병합", 시트: 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[]) : []), ]; } function rowMenuItems(rows: number[]): MapContextMenuItem[] { const at = Math.min(...rows); const 수 = rows.length; return [ [st("MenuCopy"), () => cutCopy("copy")], [ st("MenuInsertRow"), () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuDeleteRow"), () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuHide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "행", 번호: rows, 숨김: true, }), ], [ st("MenuUnhide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "행", 번호: rows, 숨김: false, }), ], ]; } function colMenuItems(cols: number[]): MapContextMenuItem[] { const at = Math.min(...cols); const 수 = cols.length; return [ [st("MenuCopy"), () => cutCopy("copy")], [ st("MenuInsertCol"), () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuDeleteCol"), () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }), ], [ st("MenuHide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "열", 번호: cols, 숨김: true, }), ], [ st("MenuUnhide"), () => ctx.dispatch({ 종류: "숨김", 시트: ctx.selection.시트, 축: "열", 번호: cols, 숨김: false, }), ], ]; } 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(); }, }; }