feat(spreadsheet): 우클릭 메뉴에 구글 시트 항목 덧붙임(81-3)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
This commit is contained in:
2026-10-08 12:54:24 +09:00
co-authored by Claude Sonnet 5
parent 49a8867892
commit 3f5dfd47b1
6 changed files with 407 additions and 10 deletions
@@ -460,7 +460,8 @@ function findBlockingMerge(sheet: Sheet, target: CellRange): boolean {
return false;
}
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
/** 블록을 `at` 에 붙임 — 우클릭 메뉴의 보안 문맥 붙여넣기(81-3)도 이 함수를 그대로 씀. */
export function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
const target: CellRange = {
r0: at.r,
c0: at.c,
+145 -7
View File
@@ -28,10 +28,35 @@ 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() {} };
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 {
@@ -79,7 +104,40 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
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] }),
@@ -107,6 +165,15 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
...(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) ?? []),
];
}
@@ -117,11 +184,24 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertRow"),
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, 수 }),
@@ -148,6 +228,25 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: 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)),
],
],
},
];
}
@@ -157,11 +256,24 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertCol"),
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, 수 }),
@@ -188,6 +300,32 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: 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))],
];
}
@@ -0,0 +1,218 @@
/* =============================================================================
* spreadsheet_menu_items.ts (주인 E · 81-3)
* 우클릭 메뉴에 덧붙이는 구글 시트 항목 — 기능은 전부 이미 있는 것만(분석_정함.md 81-3 · 조사_A 3 표(a)).
* `spreadsheet_menu.ts` 가 메뉴를 열 때 한 번만 `import()`(크기 예산 — 안 열면 안 받음).
* 안 만듦: 하이퍼링크 · 댓글 · 범위/시트 보호 · 다른 파일로 복사.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { dialogKeys } from "./spreadsheet_actions";
import { pasteBlock, parseClipboard } from "./spreadsheet_clipboard";
import { pasteSpecial } from "./spreadsheet_paste_special";
import { openInsertCellsDialog, openDeleteCellsDialog } from "./spreadsheet_insert_dialog";
import { autofitColumn } from "./spreadsheet_autofit";
import { sortSelection } from "./spreadsheet_sort_dialog";
import { toggleFilter } from "./spreadsheet_filter";
import {
freezeFirstRow,
freezeFirstColumn,
freezeAtActiveCell,
unfreezePanes,
} from "./spreadsheet_view_menu";
import { createCondFmtPanel } from "./spreadsheet_condfmt";
import { createValidationPanel } from "./spreadsheet_validation";
import { st } from "./spreadsheet_text";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/** 보안 문맥(localhost · https)에서만 시스템 클립보드를 읽음 — http(Tailscale 주소)는 null
* (부른 쪽이 Ctrl+V 안내로 되돌림, 지금처럼). */
async function readSystemClipboard(): Promise<{ html: string; text: string } | null> {
if (!window.isSecureContext || !navigator.clipboard?.read) return null;
try {
const items = await navigator.clipboard.read();
let html = "";
let text = "";
for (const item of items) {
if (item.types.includes("text/html")) html = await (await item.getType("text/html")).text();
if (item.types.includes("text/plain")) text = await (await item.getType("text/plain")).text();
}
return { html, text };
} catch {
return null; // 권한 거부 · 빈 클립보드 등
}
}
/** 메뉴 [붙여넣기] — 됐으면 true(부른 쪽은 더 안 함) · 안 됐으면 false(Ctrl+V 안내로 되돌림). */
export async function pasteFromMenu(ctx: SpreadsheetContext): Promise<boolean> {
const data = await readSystemClipboard();
if (!data) return false;
const at = { ...ctx.selection.활성 };
const block = parseClipboard(data.html, data.text, at);
if (!block) {
showToast(st("ClipboardPasteFailed"), "error");
return true;
}
pasteBlock(ctx, block, at);
return true;
}
/** 메뉴 [선택하여 붙여넣기 — 값만 · 서식만] — 결과는 위와 같은 뜻. */
export async function pasteSpecialFromMenu(
ctx: SpreadsheetContext,
mode: "값" | "서식",
): Promise<boolean> {
const data = await readSystemClipboard();
if (!data) return false;
const at = { ...ctx.selection.활성 };
const block = parseClipboard(data.html, data.text, at);
if (!block) {
showToast(st("ClipboardPasteFailed"), "error");
return true;
}
const source =
block.출처 === "aislo" && block.원점 ? { 시트: ctx.selection.시트, ...block.원점 } : undefined;
pasteSpecial(ctx, block, at, mode, source);
return true;
}
// ── 행/열 크기(숫자 창) ──────────────────────────────────────────────────────
function sizeDialog(title: string, initial: number, unit: string, onOk: (n: number) => void): void {
const input = el("input", {
attrs: { type: "number", min: "4", step: "1", value: String(Math.round(initial)) },
}) as HTMLInputElement;
input.style.cssText = "width:80px;margin-right:6px;";
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();
});
const commit = (): void => {
const n = Number(input.value);
if (Number.isFinite(n) && n > 0) onOk(n);
close();
};
panel.append(
el("h3", { text: title }),
el("div", { children: [input, el("span", { text: unit })] }),
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: "취소", variant: "ghost", onClick: close }),
createButton({ label: "확인", variant: "filled", onClick: commit }),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, close, commit);
input.focus();
input.select();
}
export function openRowSizeDialog(ctx: SpreadsheetContext, rows: number[]): void {
if (ctx.readOnly) return;
sizeDialog(st("MenuRowSize"), 15, "pt", (pt) =>
ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: rows, 높이: pt }),
);
}
export function openColSizeDialog(ctx: SpreadsheetContext, cols: number[]): void {
if (ctx.readOnly) return;
sizeDialog(
st("MenuColSize"),
64,
"px",
(px) => ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: cols, 폭: px / 7 }), // 대략 7px/문자(헤더 쪽과 같은 어림)
);
}
export function autofitCols(ctx: SpreadsheetContext, cols: number[]): void {
for (const c of cols) autofitColumn(ctx, c);
}
export function sortAsc(ctx: SpreadsheetContext): void {
sortSelection(ctx, false);
}
export function sortDesc(ctx: SpreadsheetContext): void {
sortSelection(ctx, true);
}
export function filterToggle(ctx: SpreadsheetContext): void {
toggleFilter(ctx);
}
export { freezeFirstRow, freezeFirstColumn, freezeAtActiveCell, unfreezePanes };
export function groupAxis(
ctx: SpreadsheetContext,
axis: "행" | "열",
번호: number[],
on: boolean,
): void {
ctx.dispatch({
종류: "묶음설정",
시트: ctx.sheet().id,
축: axis,
번호,
묶음: on ? { 층: 1 } : null,
});
}
// ── 조건부 서식 · 데이터 확인 · 범위 이름 — 작은 창에 패널을 그대로 띄움 ────────────
let panel: PartHandle | null = null;
let host: HTMLElement | null = null;
function closePanelHost(): void {
panel?.destroy();
panel = null;
host?.remove();
host = null;
}
function openPanelHost(
ctx: SpreadsheetContext,
title: string,
make: (c: SpreadsheetContext) => PartHandle,
): void {
closePanelHost();
const shut = el("button", { text: "×", attrs: { type: "button", title: "닫기" } });
shut.style.cssText = "margin-left:auto;";
const headRow = el("div", {
children: [el("strong", { text: title }), shut],
});
headRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:8px;";
const overlay = el("div", { className: "ui-confirm" });
const panelEl = el("div", { className: "ui-confirm__panel" });
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) closePanelHost();
});
shut.addEventListener("click", () => closePanelHost());
panelEl.append(headRow);
panel = make(ctx);
if (panel.root) panelEl.append(panel.root);
overlay.append(panelEl);
document.body.append(overlay);
host = overlay;
dialogKeys(overlay, closePanelHost);
}
export function openCondFmt(ctx: SpreadsheetContext): void {
openPanelHost(ctx, st("MenuCondFmt"), createCondFmtPanel);
}
export function openValidation(ctx: SpreadsheetContext): void {
openPanelHost(ctx, st("MenuValidation"), createValidationPanel);
}
export async function openNames(ctx: SpreadsheetContext): Promise<void> {
const m = await import("./spreadsheet_names");
openPanelHost(ctx, st("MenuNames"), m.createNameManagerPanel);
}
export function insertCells(ctx: SpreadsheetContext): void {
openInsertCellsDialog(ctx);
}
export function deleteCells(ctx: SpreadsheetContext): void {
openDeleteCellsDialog(ctx);
}
@@ -95,6 +95,32 @@ const TEXT = {
MenuCommentInsert: ["메모 삽입", "Insert comment"],
MenuCommentEdit: ["메모 편집", "Edit comment"],
MenuCommentDelete: ["메모 삭제", "Delete comment"],
// 81-3 — 구글 시트에 있고 이미 기능이 있는 것만 메뉴에 덧붙임
MenuPaste: ["붙여넣기", "Paste"],
MenuPasteSpecial: ["선택하여 붙여넣기", "Paste special"],
MenuPasteValues: ["값만 붙여넣기", "Paste values only"],
MenuPasteFormat: ["서식만 붙여넣기", "Paste format only"],
MenuInsertRowAbove: ["위에 행 삽입", "Insert row above"],
MenuInsertRowBelow: ["아래에 행 삽입", "Insert row below"],
MenuInsertColLeft: ["왼쪽에 열 삽입", "Insert column left"],
MenuInsertColRight: ["오른쪽에 열 삽입", "Insert column right"],
MenuInsertCells: ["셀 삽입…", "Insert cells…"],
MenuDeleteCells: ["셀 삭제…", "Delete cells…"],
MenuAutofit: ["데이터에 맞추기", "Fit to data"],
MenuRowSize: ["행 높이…", "Row height…"],
MenuColSize: ["열 너비…", "Column width…"],
MenuSortAsc: ["정렬 A→Z", "Sort A→Z"],
MenuSortDesc: ["정렬 Z→A", "Sort Z→A"],
MenuFilterToggle: ["필터 켜기 · 끄기", "Toggle filter"],
MenuFreeze: ["틀 고정", "Freeze"],
MenuUnfreeze: ["틀 고정 풀기", "Unfreeze"],
MenuGroup: ["그룹", "Group"],
MenuGroupOn: ["묶기", "Group"],
MenuGroupOff: ["묶음 풀기", "Ungroup"],
MenuCondFmt: ["조건부 서식…", "Conditional formatting…"],
MenuValidation: ["데이터 확인…", "Data validation…"],
MenuNames: ["범위 이름 지정…", "Named range…"],
MenuCopyOnly: ["복사", "Copy"],
ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"],
ClipboardMergeBlocked: [
"병합된 셀의 일부를 바꿀 수 없습니다",