feat(spreadsheet): 우클릭 메뉴 항목 모음 · 공용 메뉴 구분선 · 데이터 창 손잡이(81-3)

- spreadsheet_menu_items.ts 새 파일 — 붙여넣기(보안 문맥만) · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ · 읽기 전용 복사
- ui_template_context_menu 항목에 구분선 "-" 덧붙임(맨 앞 · 맨 뒤 · 겹침 안 그림)
- ctx.dataMenu — [데이터 ▾] 와 같은 패널 창 · 필터를 우클릭에서 부름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-08 12:31:40 +09:00
co-authored by Claude Opus 5.5
parent b565cdba75
commit 2ebc888f03
5 changed files with 474 additions and 2 deletions
@@ -97,6 +97,16 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
back();
};
const act = mountActions(ctx, back);
ctx.dataMenu = (kind) => {
if (kind === "filter") return (toggleFilter(), back());
if (kind === "condfmt") return void openPanel(TEXT.condfmt, async () => createCondFmtPanel);
if (kind === "validation")
return void openPanel(TEXT.validation, async () => createValidationPanel);
void openPanel(
TEXT.names,
async () => (await import("./spreadsheet_names")).createNameManagerPanel,
);
};
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
let closeSort: (() => void) | null = null;
@@ -0,0 +1,441 @@
/* =============================================================================
* 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?.();
}
/** 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 불러야 부품의 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<void> {
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<void>,
): Promise<void> {
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<void> {
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<string | null>,
): 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<void> => {
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<void> {
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<string | null> {
if (!name) return TEXT.nameEmpty;
return (await import("./spreadsheet_names")).defineName(ctx, name, g);
}
@@ -112,6 +112,8 @@ export interface SpreadsheetContext {
/** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) ·
* 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */
cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>;
/** [데이터 ▾] 와 같은 창 · 필터를 우클릭 메뉴에서 — 데이터 메뉴(`spreadsheet_data_menu.ts`)가 채움 */
dataMenu?: (kind: "filter" | "condfmt" | "validation" | "names") => void;
}
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
+7
View File
@@ -64,3 +64,10 @@
.ui-ctx-menu__item.is-sub {
padding-left: var(--spacing-24);
}
/* 구분선(`"-"` 항목) */
.ui-ctx-menu__sep {
height: 1px;
margin: var(--spacing-4) 0;
background: var(--color-border);
}
+14 -2
View File
@@ -14,10 +14,12 @@
import "./ui_template_context_menu.css";
/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로
* 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */
* 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화).
* 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */
export type MapContextMenuItem =
| [label: string, action: () => void]
| { label: string; children: Array<[label: string, action: () => void]> };
| { label: string; children: Array<[label: string, action: () => void]> }
| "-";
export interface MapContextMenu {
/** 뷰포트에 append할 요소. */
@@ -118,6 +120,14 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
enterMenu(registryEntry);
element.replaceChildren();
items.forEach((item) => {
if (item === "-") {
const last = element.lastElementChild;
if (last && !last.classList.contains("ui-ctx-menu__sep"))
element.append(
Object.assign(document.createElement("div"), { className: "ui-ctx-menu__sep" }),
);
return;
}
if (Array.isArray(item)) {
element.append(buildLeaf(item[0], item[1]));
return;
@@ -145,6 +155,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
});
element.append(head, body);
});
if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep"))
element.lastElementChild.remove();
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.hidden = false;