Files
Aislo/A00_Common/spreadsheet/spreadsheet_menu_items.ts
T
eomsangdonandClaude Opus 5.5 9c510b57b1 fix(spreadsheet): 메뉴 동작 뒤 초점 격자로 · 펼친 하위 메뉴가 화면 밖이면 올림(81-3)
- keepGridFocus — 항목을 누른 뒤 초점이 문서로 빠지면 격자 입력칸으로(Ctrl+Z 등 키가 바로 먹음)
- ui_template_context_menu — 하위(▸)를 펼칠 때도 화면 안으로 당김(fit 한 곳)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-08 13:36:18 +09:00

454 lines
16 KiB
TypeScript

/* =============================================================================
* 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<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);
}