feat(spreadsheet): 우클릭 메뉴에 붙여넣기 · 삽입/삭제 ▸ · 크기 · 정렬 · 필터 · 고정 · 그룹 · 더보기 붙임 · 읽기 전용 복사(81-3)

- spreadsheet_menu.ts — 칸 · 행 머리 · 열 머리 메뉴에 spreadsheet_menu_items 펼침 · 구분선
- 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 execCommand(메뉴가 닫히며 초점이 빠져 빈 복사되던 흠)
- 읽기 전용 · 미리보기 시트 우클릭 = 「복사」 한 줄(spreadsheet.ts 메뉴 늘 붙임)
- 시험 test_stage4_menu_items.ts 34 · 크기 화면 16.3 KB(예산 72)

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:34:54 +09:00
co-authored by Claude Opus 5.5
parent 4a61b42424
commit ea84957927
3 changed files with 343 additions and 19 deletions
+2 -2
View File
@@ -110,7 +110,7 @@ export function createSpreadsheet(
): SpreadsheetHandle {
const readOnly = !!opts.readOnly;
const noTabs = opts.tabs === false;
const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음
const bare = readOnly || !!opts.preview; // 도구 모음 없음 · 우클릭은 「복사」 한 줄
const root = el("div", {
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`,
});
@@ -142,7 +142,7 @@ export function createSpreadsheet(
ctx.editor = ed;
const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx);
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
const menu: PartHandle | null = bare ? null : attachMenu(ctx);
const menu: PartHandle = attachMenu(ctx); // 읽기 전용 · 미리보기는 「복사」 한 줄(81-3)
const clip = attachClipboard(ctx);
const status = createStatusBar(ctx);
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
+37 -17
View File
@@ -12,10 +12,21 @@
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createButton, el } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { confirmMerge, dialogKeys } from "./spreadsheet_actions";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import {
cellInsertItems,
cellMoreItems,
colInsertItems,
colMoreItems,
copyCut,
copyOnlyItems,
pasteItems,
rowInsertItems,
rowMoreItems,
} from "./spreadsheet_menu_items";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -28,7 +39,8 @@ 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() {} };
// 읽기 전용 · 미리보기 = 「복사」 한 줄(81-3)
const bare = ctx.readOnly || ctx.root.classList.contains("ss--preview");
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
@@ -39,7 +51,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
}
function cutCopy(kind: "cut" | "copy"): void {
document.execCommand(kind);
copyCut(ctx, kind);
}
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
@@ -79,7 +91,10 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
...pasteItems(ctx),
"-",
...cellInsertItems(ctx, range),
"-",
[
st("MenuClearContent"),
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
@@ -107,6 +122,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
...cellMoreItems(ctx, range),
...(ctx.cellMenuExtra?.(r, c) ?? []),
];
}
@@ -117,11 +133,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertRow"),
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
],
...pasteItems(ctx),
"-",
...rowInsertItems(ctx, at, 수),
[
st("MenuDeleteRow"),
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
@@ -148,6 +162,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: false,
}),
],
...rowMoreItems(ctx, rows),
];
}
@@ -157,11 +172,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertCol"),
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
],
...pasteItems(ctx),
"-",
...colInsertItems(ctx, at, 수),
[
st("MenuDeleteCol"),
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
@@ -188,6 +201,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: false,
}),
],
...colMoreItems(ctx, cols),
];
}
@@ -217,7 +231,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
활성: { r: hit.r, c: 0 },
기준: { r: hit.r, c: 0 },
});
menu.open(x, y, rowMenuItems(rows));
menu.open(x, y, bare ? copyOnlyItems(ctx) : rowMenuItems(rows));
return;
}
@@ -235,7 +249,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
활성: { r: 0, c: hit.c },
기준: { r: 0, c: hit.c },
});
menu.open(x, y, colMenuItems(cols));
menu.open(x, y, bare ? copyOnlyItems(ctx) : colMenuItems(cols));
return;
}
@@ -250,7 +264,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
});
}
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
menu.open(
x,
y,
bare
? [...copyOnlyItems(ctx), ...(ctx.cellMenuExtra?.(hit.r, hit.c) ?? [])]
: cellMenuItems(active, hit.r, hit.c),
);
}
}
@@ -0,0 +1,304 @@
/* =============================================================================
* test_stage4_menu_items.ts — 우클릭 메뉴 덧붙인 항목(81-3) 시험.
* 행 위/아래 · 열 좌우 삽입 · 셀 삽입/삭제 · 행열 삭제 · 크기 · 시트 정렬 · 필터 · 고정 · 그룹 ·
* 범위 이름 · 붙여넣기 보안 문맥 갈래 · 구분선 · 읽기 전용 복사.
* 돌리기: npx tsx resources/tester/spreadsheet/test_stage4_menu_items.ts
* ========================================================================== */
import { register } from "node:module";
import type { MapContextMenuItem } from "../../../ui_template/ui_template_context_menu.ts";
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands.ts";
import { createCalcEngine } from "../../../A00_Common/spreadsheet/spreadsheet_graph.ts";
import {
canMenuPaste,
cellInsertItems,
cellMoreItems,
colInsertItems,
colMoreItems,
copyOnlyItems,
menuPaste,
nameRange,
pasteItems,
resizeLines,
rowInsertItems,
rowMoreItems,
sortByColumn,
usedRange,
} from "../../../A00_Common/spreadsheet/spreadsheet_menu_items.ts";
import type { Cell, Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
import type {
Selection,
SpreadsheetContext,
} from "../../../A00_Common/spreadsheet/spreadsheet_view_types.ts";
import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
// 늦게 부르는 창 모듈의 css 는 node 에서 빈 모듈로
register(
"data:text/javascript,export async function load(u,c,n){return u.endsWith('.css')?{format:'module',source:'',shortCircuit:true}:n(u,c)}",
);
const tick = () => new Promise((r) => setTimeout(r, 30));
/** 진짜 명령 · 엔진 · 고름을 쓰는 문맥 */
function realCtx(cells: Record<string, Cell>): SpreadsheetContext & { sent: Command[] } {
const book: Workbook = makeBook([makeSheet("s1", cells)]);
const base = makeCtx(book, "s1");
const sent: Command[] = [];
const ctx = base as unknown as SpreadsheetContext & { sent: Command[] };
ctx.engine = createCalcEngine(book);
ctx.sent = sent;
ctx.dispatch = (cmd: Command) => {
sent.push(cmd);
applyCommand(book, cmd);
ctx.engine.rebuild(book);
};
ctx.select = (sel: Selection) => void (ctx.selection = sel);
return ctx;
}
const col = (n: number) =>
Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(i, 0), v(i + 1)]));
const row = (n: number) =>
Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(0, i), v(i + 1)]));
const val = (ctx: SpreadsheetContext, r: number, c: number) => ctx.sheet().칸[a1(r, c)]?.값;
const leaf = (items: MapContextMenuItem[], label: string): (() => void) => {
for (const it of items) {
if (it === "-") continue;
if (Array.isArray(it)) {
if (it[0] === label) return it[1];
} else {
const hit = it.children.find(([l]) => l === label);
if (hit) return hit[1];
}
}
throw new Error(`항목 없음: ${label}`);
};
const labels = (items: MapContextMenuItem[]) =>
items.map((it) => (it === "-" ? "-" : Array.isArray(it) ? it[0] : `${it.label} ▸`));
// ── 행 위 · 아래 삽입(고른 2행 B2:B3 → at 1 · 수 2) ─────────────────────────
{
const ctx = realCtx(col(5));
leaf(rowInsertItems(ctx, 1, 2), "위에 2행 삽입")();
assertEqual(
[val(ctx, 0, 0), val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)],
[1, undefined, undefined, 2],
"위에 2행 — 2행부터 밀림",
);
}
{
const ctx = realCtx(col(5));
leaf(rowInsertItems(ctx, 1, 2), "아래에 2행 삽입")();
assertEqual(
[val(ctx, 2, 0), val(ctx, 3, 0), val(ctx, 4, 0), val(ctx, 5, 0)],
[3, undefined, undefined, 4],
"아래에 2행 — 고른 줄 뒤에 끼움",
);
}
// ── 열 왼 · 오른쪽 삽입 ──────────────────────────────────────────────────
{
const ctx = realCtx(row(4));
leaf(colInsertItems(ctx, 1, 1), "왼쪽에 1열 삽입")();
assertEqual([val(ctx, 0, 0), val(ctx, 0, 1), val(ctx, 0, 2)], [1, undefined, 2], "왼쪽에 1열");
}
{
const ctx = realCtx(row(4));
leaf(colInsertItems(ctx, 1, 1), "오른쪽에 1열 삽입")();
assertEqual([val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], [2, undefined, 3], "오른쪽에 1열");
}
// ── 칸 메뉴 — 셀 삽입/삭제 ▸ · 행열 삭제 ▸ ──────────────────────────────
{
const g = { r0: 0, c0: 1, r1: 0, c1: 1 };
const ctx = realCtx(row(3));
const items = cellInsertItems(ctx, g);
assertEqual(
labels(items),
["행 · 열 삽입 ▸", "셀 삽입 ▸", "행 · 열 삭제 ▸", "셀 삭제 ▸"],
"칸 메뉴 하위 넷",
);
leaf(items, "셀 삽입 후 오른쪽으로 밀기")();
await tick();
assertEqual(
[val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)],
[undefined, 2, 3],
"셀 삽입 · 오른쪽으로",
);
leaf(cellInsertItems(ctx, g), "셀 삭제 후 왼쪽으로 당기기")();
await tick();
assertEqual([val(ctx, 0, 1), val(ctx, 0, 2)], [2, 3], "셀 삭제 · 왼쪽으로");
}
{
const ctx = realCtx(col(3));
leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삽입 후 아래로 밀기")();
await tick();
assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [undefined, 2], "셀 삽입 · 아래로");
leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삭제 후 위로 당기기")();
await tick();
assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [2, 3], "셀 삭제 · 위로");
leaf(cellInsertItems(ctx, { r0: 0, c0: 0, r1: 1, c1: 0 }), "2행 삭제")();
assertEqual(val(ctx, 0, 0), 3, "칸 메뉴 행 삭제 · 고른 2행");
}
// ── 크기 — px → 열폭(글자) · 행높이(pt) · 잘못된 값 ─────────────────────────
{
const ctx = realCtx(row(2));
assertEqual(
resizeLines(ctx, false, [0, 1], "abc"),
"1 이상 숫자를 넣을 것",
"크기 · 글이면 거름",
);
assertEqual(resizeLines(ctx, false, [0, 1], "0"), "1 이상 숫자를 넣을 것", "크기 · 0 거름");
assertEqual(resizeLines(ctx, false, [0, 1], "100"), null, "열 너비 100px");
const w = ctx.sheet().열!;
assertEqual(w.A?.폭 === w.B?.폭 && (w.A?.폭 ?? 0) > 10, true, "열 둘 같은 폭(한 명령)");
resizeLines(ctx, true, [2], "40");
assertEqual(
Math.round((ctx.sheet().행?.["3"]?.높이 ?? 0) * 100) / 100,
30,
"행 높이 40px = 30pt",
);
}
// ── 시트 정렬(열 머리) — 쓴 범위 통째 · 고름 되돌림 · 빈 열은 안 함 ───────────────
{
const ctx = realCtx({
A1: v("이름"),
B1: v("값"),
A2: v("다"),
B2: v(3),
A3: v("가"),
B3: v(1),
A4: v("나"),
B4: v(2),
});
const keep = ctx.selection;
await sortByColumn(ctx, 1, false);
assertEqual(
[val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)],
["가", "나", "다"],
"B 열 기준 오름 — A 열도 같이",
);
assertEqual(val(ctx, 0, 0), "이름", "머리 행 그대로");
assertEqual(ctx.selection, keep, "정렬 뒤 고름 되돌림");
await sortByColumn(ctx, 1, true);
assertEqual([val(ctx, 1, 1), val(ctx, 3, 1)], [3, 1], "내림");
const before = ctx.sent.length;
await sortByColumn(ctx, 5, false);
assertEqual(ctx.sent.length, before, "빈 열 정렬은 명령 없음");
assertEqual(usedRange(ctx.sheet()), { r0: 0, c0: 0, r1: 3, c1: 1 }, "쓴 범위");
}
// ── 필터 · 고정 · 그룹 · 더보기 ─────────────────────────────────────────────
{
const ctx = realCtx({ A1: v("머리"), A2: v(1), B2: v(2) });
const calls: [string, unknown][] = [];
ctx.dataMenu = (kind) => calls.push([kind, structuredClone(ctx.selection.범위)]);
const more = colMoreItems(ctx, [0]);
assertEqual(
labels(more),
[
"-",
"열 크기 조절…",
"데이터에 맞추기",
"-",
"시트 정렬 A → Z",
"시트 정렬 Z → A",
"필터 만들기",
"-",
"고정 ▸",
"그룹 ▸",
],
"열 머리 덧붙임 순서",
);
leaf(more, "필터 만들기")();
await tick();
assertEqual(calls[0], ["filter", [{ r0: 0, c0: 0, r1: 1, c1: 1 }]], "열 머리 필터 = 쓴 범위로");
leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "드롭다운…")();
leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "조건부 서식…")();
assertEqual(
calls.slice(1).map((c) => c[0]),
["validation", "condfmt"],
"더보기 → 데이터 창",
);
leaf(rowMoreItems(ctx, [0]), "첫 행 고정")();
await tick();
assertEqual(ctx.sent.at(-1)?.종류, "틀고정", "고정 ▸ 첫 행");
ctx.selection = {
시트: "s1",
범위: [{ r0: 1, c0: 0, r1: 1, c1: 16383 }],
활성: { r: 1, c: 0 },
기준: { r: 1, c: 0 },
};
leaf(rowMoreItems(ctx, [1]), "그룹으로 묶기")();
await tick();
assertEqual(ctx.sheet().행?.["2"]?.묶음, { 층: 1 }, "그룹 ▸ 묶기");
assertEqual(
await nameRange(ctx, "", { r0: 0, c0: 0, r1: 0, c1: 0 }),
"이름을 넣을 것",
"범위 이름 · 빈 값",
);
assertEqual(
await nameRange(ctx, "합계칸", { r0: 1, c0: 0, r1: 1, c1: 1 }),
null,
"범위 이름 지정",
);
assertEqual(
typeof (await nameRange(ctx, "합계칸", { r0: 0, c0: 0, r1: 0, c1: 0 })),
"string",
"같은 이름은 거름",
);
}
// ── 붙여넣기 — 보안 문맥 갈래 ───────────────────────────────────────────────
{
const g = globalThis as Record<string, unknown>;
g.isSecureContext = false;
assertEqual(canMenuPaste(), false, "http(보안 문맥 아님) — 메뉴로 못 읽음 → Ctrl+V 안내");
g.isSecureContext = true;
assertEqual(canMenuPaste(), false, "클립보드 API 없음 — 안내");
const clip = { html: "", text: "7\t8" };
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
read: async () => [
{
types: ["text/plain"],
getType: async () => new Blob([clip.text]),
},
],
},
});
assertEqual(canMenuPaste(), true, "localhost · https — 메뉴로 읽음");
class FakeData {
data: Record<string, string> = {};
setData(k: string, s: string) {
this.data[k] = s;
}
}
class FakePaste {
constructor(
public type: string,
public init: { clipboardData: FakeData },
) {}
}
g.DataTransfer = FakeData;
g.ClipboardEvent = FakePaste;
const ctx = realCtx({});
const events: FakePaste[] = [];
ctx.root = { dispatchEvent: (e: FakePaste) => events.push(e) } as unknown as HTMLElement;
await menuPaste(ctx);
assertEqual(
[events[0]?.type, events[0]?.init.clipboardData.data["text/plain"]],
["paste", "7\t8"],
"보통 붙여넣기 = 부품 paste 길로",
);
ctx.selection = {
시트: "s1",
범위: [{ r0: 2, c0: 0, r1: 2, c1: 0 }],
활성: { r: 2, c: 0 },
기준: { r: 2, c: 0 },
};
await menuPaste(ctx, "값");
assertEqual([val(ctx, 2, 0), val(ctx, 2, 1)], [7, 8], "값만 붙여넣기 — 활성 칸부터");
assertEqual(labels(pasteItems(ctx)), ["붙여넣기", "선택하여 붙여넣기 ▸"], "붙여넣기 항목");
}
// ── 읽기 전용 — 복사 한 줄 ─────────────────────────────────────────────────
{
const ctx = realCtx({});
assertEqual(labels(copyOnlyItems(ctx)), ["복사"], "읽기 전용 · 미리보기 = 복사 한 줄");
}
console.log("spreadsheet_menu_items 시험 끝");