/* ============================================================================= * spreadsheet_menu_items.ts (81-3) * 우클릭 메뉴에 덧붙이는 항목 — 구글 시트 우클릭 가운데 기능이 이미 있는 것만(조사_A 3 표 (a)). * 붙여넣기 · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 행 · 열 크기 · 데이터에 맞추기 · * 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸(드롭다운 · 조건부 서식 · 범위 이름). * 안 만듦 = 하이퍼링크 · 댓글 · 범위/시트 보호 · 다른 파일로 복사. * `spreadsheet_menu.ts` 가 이 목록을 펼쳐 붙임. 몸통 창 · 명령 모듈은 누를 때 `import()`(크기 예산). * 붙여넣기는 보안 문맥(localhost · https)에서만 클립보드를 읽어 실제로 붙임 — http 는 Ctrl+V 안내. * ========================================================================== */ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { dialogKeys, type Act } from "./spreadsheet_actions"; import { parseA1 } from "./spreadsheet_address"; import { parseClipboard } from "./spreadsheet_clipboard"; import type { Editor } from "./spreadsheet_editor"; import { buildColGeometry, buildRowGeometry, MAX_COL_CHARS, pxToColChars, pxToPt, } from "./spreadsheet_headers"; import type { PasteSpecialMode } from "./spreadsheet_paste_special"; import { st } from "./spreadsheet_text"; import type { CellRange, Command, Sheet } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; const TEXT = { paste: "붙여넣기", pasteSpecial: "선택하여 붙여넣기", pasteValues: "값만 붙여넣기", pasteFormat: "서식만 붙여넣기", pasteSpecialHint: "선택하여 붙여넣기는 Ctrl+Shift+V 를 쓸 것", pasteFailed: "붙여넣을 내용이 없음", insertLines: "행 · 열 삽입", rowsAbove: (n: number) => `위에 ${n}행 삽입`, rowsBelow: (n: number) => `아래에 ${n}행 삽입`, colsLeft: (n: number) => `왼쪽에 ${n}열 삽입`, colsRight: (n: number) => `오른쪽에 ${n}열 삽입`, insertCells: "셀 삽입", shiftRight: "셀 삽입 후 오른쪽으로 밀기", shiftDown: "셀 삽입 후 아래로 밀기", deleteLines: "행 · 열 삭제", deleteRows: (n: number) => `${n}행 삭제`, deleteCols: (n: number) => `${n}열 삭제`, deleteCells: "셀 삭제", shiftLeft: "셀 삭제 후 왼쪽으로 당기기", shiftUp: "셀 삭제 후 위로 당기기", rowSize: "행 크기 조절…", colSize: "열 크기 조절…", rowSizeLabel: "행 높이(px)", colSizeLabel: "열 너비(px)", autofit: "데이터에 맞추기", sortAsc: "시트 정렬 A → Z", sortDesc: "시트 정렬 Z → A", filterOn: "필터 만들기", filterOff: "필터 삭제", freeze: "고정", freezeRow: "첫 행 고정", freezeCol: "첫 열 고정", freezeAt: "현재 칸까지 고정", unfreeze: "고정 풀기", group: "그룹", groupOn: "그룹으로 묶기", groupOff: "그룹 풀기", more: "셀 작업 더보기", dropdown: "드롭다운…", condfmt: "조건부 서식…", nameRange: "범위 이름 지정…", nameLabel: "이름", nameEmpty: "이름을 넣을 것", ok: "확인", cancel: "취소", badNumber: "1 이상 숫자를 넣을 것", }; type Item = [string, () => void]; const SEP = "-" as const; /** 메뉴를 닫으면 누른 단추가 지워져 초점이 문서 밖으로 감 — 격자 입력칸으로 되돌림 */ function back(ctx: SpreadsheetContext): void { (ctx.editor as Editor).focus?.(); } /** 항목을 누른 뒤 초점이 문서(body)에 떨어졌으면 격자로 — 창을 띄운 동작은 그 창이 초점을 가짐 */ export function keepGridFocus(ctx: SpreadsheetContext, menuRoot: HTMLElement): void { menuRoot.addEventListener( "click", () => setTimeout(() => { if (document.activeElement === document.body) back(ctx); }), true, ); } /** 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 불러야 부품의 copy · cut 듣기가 받음(빈 복사 막음) */ export function copyCut(ctx: SpreadsheetContext, kind: "cut" | "copy"): void { back(ctx); document.execCommand(kind); } /** 읽기 전용 · 미리보기 시트 — 「복사」 한 줄 */ export function copyOnlyItems(ctx: SpreadsheetContext): MapContextMenuItem[] { return [[st("MenuCopy"), () => copyCut(ctx, "copy")]]; } const id = (ctx: SpreadsheetContext): string => ctx.selection.시트; /** 메뉴로 클립보드를 읽을 수 있는가 — 보안 문맥(localhost · https)만 */ export function canMenuPaste(): boolean { return globalThis.isSecureContext === true && typeof navigator?.clipboard?.read === "function"; } async function readClipboard(): Promise<{ html: string; text: string } | null> { if (!canMenuPaste()) return null; try { let html = ""; let text = ""; for (const item of await navigator.clipboard.read()) { if (!html && item.types.includes("text/html")) html = await (await item.getType("text/html")).text(); if (!text && item.types.includes("text/plain")) text = await (await item.getType("text/plain")).text(); } return { html, text }; } catch { return null; // 권한 거절 — 안내로 } } /** 메뉴 붙여넣기 — 보통은 부품의 paste 듣기로(잘라내기 옮김 · 엑셀 경고 그대로) · 골라 붙이기는 바로 */ export async function menuPaste(ctx: SpreadsheetContext, mode?: PasteSpecialMode): Promise { const clip = await readClipboard(); if (!clip) return showToast(mode ? TEXT.pasteSpecialHint : st("MenuPasteHint"), "info"); back(ctx); if (!mode) { const data = new DataTransfer(); data.setData("text/html", clip.html); data.setData("text/plain", clip.text); ctx.root.dispatchEvent( new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }), ); return; } const at = { ...ctx.selection.활성 }; const block = parseClipboard(clip.html, clip.text, at); if (!block) return showToast(TEXT.pasteFailed, "error"); const source = block.출처 === "aislo" && block.원점 ? { 시트: id(ctx), ...block.원점 } : undefined; (await import("./spreadsheet_paste_special")).pasteSpecial(ctx, block, at, mode, source); } export function pasteItems(ctx: SpreadsheetContext): MapContextMenuItem[] { return [ [TEXT.paste, () => void menuPaste(ctx)], { label: TEXT.pasteSpecial, children: [ [TEXT.pasteValues, () => void menuPaste(ctx, "값")], [TEXT.pasteFormat, () => void menuPaste(ctx, "서식")], ], }, ]; } /** 행 · 열 넣기 — 위 · 왼쪽은 `at` · 아래 · 오른쪽은 `at + 수`(고른 줄 수만큼) */ export function insertLines( ctx: SpreadsheetContext, rows: boolean, at: number, 수: number, after: boolean, ): void { ctx.dispatch({ 종류: rows ? "행넣기" : "열넣기", 시트: id(ctx), at: after ? at + 수 : at, 수 }); } function deleteLines(ctx: SpreadsheetContext, rows: boolean, at: number, 수: number): void { ctx.dispatch({ 종류: rows ? "행지우기" : "열지우기", 시트: id(ctx), at, 수 }); } export function rowInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] { return [ [TEXT.rowsAbove(수), () => insertLines(ctx, true, at, 수, false)], [TEXT.rowsBelow(수), () => insertLines(ctx, true, at, 수, true)], ]; } export function colInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] { return [ [TEXT.colsLeft(수), () => insertLines(ctx, false, at, 수, false)], [TEXT.colsRight(수), () => insertLines(ctx, false, at, 수, true)], ]; } const shifts = () => import("./spreadsheet_insert_dialog"); /** 칸 메뉴 — 행 · 열 · 셀 삽입/삭제 ▸ */ export function cellInsertItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] { const rows = g.r1 - g.r0 + 1; const cols = g.c1 - g.c0 + 1; return [ { label: TEXT.insertLines, children: [...rowInsertItems(ctx, g.r0, rows), ...colInsertItems(ctx, g.c0, cols)], }, { label: TEXT.insertCells, children: [ [TEXT.shiftRight, () => void shifts().then((m) => m.insertShiftRight(ctx, g))], [TEXT.shiftDown, () => void shifts().then((m) => m.insertShiftDown(ctx, g))], ], }, { label: TEXT.deleteLines, children: [ [TEXT.deleteRows(rows), () => deleteLines(ctx, true, g.r0, rows)], [TEXT.deleteCols(cols), () => deleteLines(ctx, false, g.c0, cols)], ], }, { label: TEXT.deleteCells, children: [ [TEXT.shiftLeft, () => void shifts().then((m) => m.deleteShiftLeft(ctx, g))], [TEXT.shiftUp, () => void shifts().then((m) => m.deleteShiftUp(ctx, g))], ], }, ]; } /** 몸통은 [데이터 ▾] 와 같은 늦은 길(`spreadsheet_actions_run.ts`) */ function run(ctx: SpreadsheetContext, act: Act): void { void import("./spreadsheet_actions_run").then((m) => m.run(ctx, () => back(ctx), act)); } function freezeGroupItems(ctx: SpreadsheetContext): MapContextMenuItem[] { return [ { label: TEXT.freeze, children: [ [TEXT.freezeRow, () => run(ctx, "freezeRow")], [TEXT.freezeCol, () => run(ctx, "freezeCol")], [TEXT.freezeAt, () => run(ctx, "freezeAt")], [TEXT.unfreeze, () => run(ctx, "unfreeze")], ], }, { label: TEXT.group, children: [ [TEXT.groupOn, () => run(ctx, "group")], [TEXT.groupOff, () => run(ctx, "ungroup")], ], }, ]; } /** 쓴 칸을 모두 덮는 범위(없으면 null) */ export function usedRange(sheet: Sheet): CellRange | null { let g: CellRange | null = null; for (const key of Object.keys(sheet.칸)) { const p = parseA1(key); if (!p) continue; g = g ? { r0: Math.min(g.r0, p.r), c0: Math.min(g.c0, p.c), r1: Math.max(g.r1, p.r), c1: Math.max(g.c1, p.c), } : { r0: p.r, c0: p.c, r1: p.r, c1: p.c }; } return g; } /** 열 머리에서 — 쓴 범위를 잠깐 골라 `job`(정렬 · 필터) 뒤 고름을 되돌림. 빈 열이면 안 함 */ async function onUsed( ctx: SpreadsheetContext, c: number, job: () => void | Promise, ): Promise { const used = usedRange(ctx.sheet()); if (!used || c < used.c0 || c > used.c1) return; const keep = ctx.selection; const top = { r: used.r0, c }; ctx.select({ 시트: id(ctx), 범위: [used], 활성: top, 기준: top }); try { await job(); } finally { ctx.select(keep); } } /** 시트 정렬 — 쓴 범위 전체를 이 열 기준으로(열 하나만 섞이지 않게) */ export function sortByColumn(ctx: SpreadsheetContext, c: number, desc: boolean): Promise { return onUsed(ctx, c, async () => (await import("./spreadsheet_sort_dialog")).sortSelection(ctx, desc), ); } function filterItem(ctx: SpreadsheetContext, c?: number): Item { const on = !!ctx.sheet().필터; const go = (): void => ctx.dataMenu?.("filter"); return [ on ? TEXT.filterOff : TEXT.filterOn, () => (on || c === undefined ? go() : void onUsed(ctx, c, go)), ]; } /** 숫자 · 글 하나 받는 작은 창 — 확인이 거르면(오류 글) 창이 남음 */ function askValue( ctx: SpreadsheetContext, title: string, label: string, value: string, submit: (text: string) => string | null | Promise, ): void { const overlay = el("div", { className: "ui-confirm" }); const close = (): void => { overlay.remove(); back(ctx); }; const field = createInputField({ label, value }); const error = el("p", { text: "" }); error.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;"; const ok = async (): Promise => { const fail = await submit(field.input.value.trim()); if (fail) error.textContent = fail; else close(); }; field.input.addEventListener("keydown", (e) => { if (e.key === "Enter") void ok(); }); overlay.append( el("div", { className: "ui-confirm__panel", children: [ el("h3", { text: title }), field.root, error, el("div", { className: "ui-confirm__actions", children: [ createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), createButton({ label: TEXT.ok, variant: "filled", onClick: () => void ok() }), ], }), ], }), ); document.body.append(overlay); dialogKeys(overlay, close); field.input.focus(); field.input.select(); } /** 행 높이 · 열 너비 바꾸기(px) — 고른 줄 모두 한 명령(한 번에 되돌림) · 잘못된 값이면 오류 글 */ export function resizeLines( ctx: SpreadsheetContext, rows: boolean, lines: number[], text: string, ): string | null { const px = Number(text); if (!Number.isFinite(px) || px < 1) return TEXT.badNumber; ctx.dispatch( rows ? { 종류: "행높이", 시트: id(ctx), 행: lines, 높이: Math.min(pxToPt(px), 409) } : { 종류: "열폭", 시트: id(ctx), 열: lines, 폭: Math.min(pxToColChars(px), MAX_COL_CHARS) }, ); return null; } function sizeItem(ctx: SpreadsheetContext, rows: boolean, lines: number[]): Item { return [ rows ? TEXT.rowSize : TEXT.colSize, () => { const geo = (rows ? buildRowGeometry : buildColGeometry)(ctx.book, ctx.sheet()); askValue( ctx, rows ? TEXT.rowSize : TEXT.colSize, rows ? TEXT.rowSizeLabel : TEXT.colSizeLabel, String(Math.round(geo.size(lines[0]))), (text) => resizeLines(ctx, rows, lines, text), ); }, ]; } /** 열 너비를 데이터에 맞춤 — 고른 열 모두 한 명령 */ export async function autofitColumns(ctx: SpreadsheetContext, cols: number[]): Promise { const { estimateColumnWidth } = await import("./spreadsheet_autofit"); const 명령: Command[] = cols.map((c) => ({ 종류: "열폭", 시트: id(ctx), 열: [c], 폭: estimateColumnWidth(ctx.book, id(ctx), c), })); ctx.dispatch({ 종류: "묶음", 명령 }); } /** 행 머리 끝 — 크기 · 고정 ▸ · 그룹 ▸ */ export function rowMoreItems(ctx: SpreadsheetContext, rows: number[]): MapContextMenuItem[] { return [SEP, sizeItem(ctx, true, rows), SEP, ...freezeGroupItems(ctx)]; } /** 열 머리 끝 — 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ */ export function colMoreItems(ctx: SpreadsheetContext, cols: number[]): MapContextMenuItem[] { const c = cols[0]; return [ SEP, sizeItem(ctx, false, cols), [TEXT.autofit, () => void autofitColumns(ctx, cols)], SEP, [TEXT.sortAsc, () => void sortByColumn(ctx, c, false)], [TEXT.sortDesc, () => void sortByColumn(ctx, c, true)], filterItem(ctx, c), SEP, ...freezeGroupItems(ctx), ]; } /** 칸 메뉴 끝 — 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ */ export function cellMoreItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] { return [ SEP, ...freezeGroupItems(ctx), { label: TEXT.more, children: [ [TEXT.dropdown, () => ctx.dataMenu?.("validation")], [TEXT.condfmt, () => ctx.dataMenu?.("condfmt")], [ TEXT.nameRange, () => askValue(ctx, TEXT.nameRange, TEXT.nameLabel, "", (name) => nameRange(ctx, name, g)), ], filterItem(ctx), ], }, SEP, ]; } /** 범위 이름 — `spreadsheet_names.defineName`(오류 글 · 성공 null) */ export async function nameRange( ctx: SpreadsheetContext, name: string, g: CellRange, ): Promise { if (!name) return TEXT.nameEmpty; return (await import("./spreadsheet_names")).defineName(ctx, name, g); }