Files
Aislo/A00_Common/spreadsheet/spreadsheet_menu.ts
T
eomsangdon 57ddb3866d feat(spreadsheet): 칸 우클릭 메뉴에 메모 삽입 · 편집 · 삭제
- sub1 `spreadsheet_comments.ts` 의 setComment · deleteComment · getComment 호출
- 글 입력은 E 자체 작은 오버레이(native prompt 안 씀 — 자동화 창 자동 취소 문제)
- ORCA 실 엔진 시험 — 삽입 → 메뉴 글자가 「메모 편집·삭제」로 바뀜 → 삭제 → 취소(저장 안 됨) 확인
2026-09-27 20:59:25 +09:00

258 lines
8.8 KiB
TypeScript

/* =============================================================================
* spreadsheet_menu.ts (주인 E)
* 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) ·
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기 · 메모 삽입/편집/삭제.
* `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김.
* 메모는 `spreadsheet_comments.ts`(sub1 · 브레인 답 2026-09-27)의 `setComment` · `deleteComment` ·
* `getComment` 를 부름 — 안에서 「시트통째」 명령 · 되돌리기 짝까지 만듦. 글 입력 창은 이 파일이
* 직접 띄움(작은 오버레이 · `showConfirmDialog` 와 같은 결의 자체 모달 · native prompt 안 씀).
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachMenu(ctx)` 한 번 부름(반환 `root` = 메뉴 껍데기 ·
* 안 붙여도 됨 · `ctx.root` 안에 스스로 넣음). `ctx.grid.hitTest` 가 준비된 뒤(C 격자 붙은 뒤) 불러야 함.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_toolbar.css";
function within(range: CellRange, r: number, c: number): boolean {
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
}
/** `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);
function isMerged(range: CellRange): boolean {
return (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
}
function cutCopy(kind: "cut" | "copy"): void {
document.execCommand(kind);
}
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
* 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */
function openCommentEditor(initial: string, onSave: (text: string) => void): void {
const overlay = el("div", { className: "ss-comment-editor" });
const panel = el("div", { className: "ss-comment-editor__panel" });
const textarea = el("textarea", {
className: "ss-comment-editor__input",
}) as HTMLTextAreaElement;
textarea.value = initial;
const done = (): void => overlay.remove();
const actions = el("div", {
className: "ss-comment-editor__actions",
children: [
createButton({ label: "취소", variant: "ghost", onClick: done }),
createButton({
label: "저장",
variant: "filled",
onClick: () => {
onSave(textarea.value);
done();
},
}),
],
});
panel.append(textarea, actions);
overlay.append(panel);
document.body.append(overlay);
textarea.focus();
textarea.select();
}
function cellMenuItems(range: CellRange, r: number, c: number): MapContextMenuItem[] {
const note = getComment(ctx.sheet(), r, c);
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuClearContent"),
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
],
[
st("MenuClearFormat"),
() => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }),
],
[
isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"),
() =>
ctx.dispatch(
isMerged(range)
? { 종류: "병합풀기", 시트: ctx.selection.시트, 범위: range }
: { 종류: "병합", 시트: ctx.selection.시트, 범위: range },
),
],
[
note ? st("MenuCommentEdit") : st("MenuCommentInsert"),
() => openCommentEditor(note ?? "", (text) => setComment(ctx, r, c, text)),
],
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
];
}
function rowMenuItems(rows: number[]): MapContextMenuItem[] {
const at = Math.min(...rows);
const 수 = rows.length;
return [
[st("MenuCopy"), () => cutCopy("copy")],
[
st("MenuInsertRow"),
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuDeleteRow"),
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuHide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "행",
번호: rows,
숨김: true,
}),
],
[
st("MenuUnhide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "행",
번호: rows,
숨김: false,
}),
],
];
}
function colMenuItems(cols: number[]): MapContextMenuItem[] {
const at = Math.min(...cols);
const 수 = cols.length;
return [
[st("MenuCopy"), () => cutCopy("copy")],
[
st("MenuInsertCol"),
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuDeleteCol"),
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuHide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "열",
번호: cols,
숨김: true,
}),
],
[
st("MenuUnhide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "열",
번호: cols,
숨김: false,
}),
],
];
}
function range(r0: number, c0: number, r1: number, c1: number): CellRange {
return { r0, c0, r1, c1 };
}
function onContextMenu(ev: MouseEvent): void {
const hit = ctx.grid.hitTest(ev.clientX, ev.clientY);
if (!hit) return;
ev.preventDefault();
const box = ctx.root.getBoundingClientRect();
const x = ev.clientX - box.left;
const y = ev.clientY - box.top;
if (hit.kind === "rowHead") {
const covered = ctx.selection.범위.find(
(rg) => rg.c0 === 0 && rg.c1 >= MAX_COLS - 1 && within(rg, hit.r, 0),
);
const rows = covered
? Array.from({ length: covered.r1 - covered.r0 + 1 }, (_, i) => covered.r0 + i)
: [hit.r];
if (!covered)
ctx.select({
시트: ctx.selection.시트,
범위: [range(hit.r, 0, hit.r, MAX_COLS - 1)],
활성: { r: hit.r, c: 0 },
기준: { r: hit.r, c: 0 },
});
menu.open(x, y, rowMenuItems(rows));
return;
}
if (hit.kind === "colHead") {
const covered = ctx.selection.범위.find(
(rg) => rg.r0 === 0 && rg.r1 >= MAX_ROWS - 1 && within(rg, 0, hit.c),
);
const cols = covered
? Array.from({ length: covered.c1 - covered.c0 + 1 }, (_, i) => covered.c0 + i)
: [hit.c];
if (!covered)
ctx.select({
시트: ctx.selection.시트,
범위: [range(0, hit.c, MAX_ROWS - 1, hit.c)],
활성: { r: 0, c: hit.c },
기준: { r: 0, c: hit.c },
});
menu.open(x, y, colMenuItems(cols));
return;
}
if (hit.kind === "cell") {
const covered = ctx.selection.범위.some((rg) => within(rg, hit.r, hit.c));
if (!covered) {
ctx.select({
시트: ctx.selection.시트,
범위: [range(hit.r, hit.c, hit.r, hit.c)],
활성: { r: hit.r, c: hit.c },
기준: { r: hit.r, c: hit.c },
});
}
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
}
}
ctx.root.addEventListener("contextmenu", onContextMenu);
return {
root: menu.element,
refresh() {},
destroy() {
ctx.root.removeEventListener("contextmenu", onContextMenu);
menu.close();
menu.element.remove();
},
};
}