M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 · 이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 · 수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 · 취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠. - 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이 빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림 - 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 · 테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글 대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91) - 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge) - 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때 초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 · Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이 - 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약 우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리 메뉴에 잘라내기 · 붙여넣기(안내) 더함 - 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면 위로 뒤집음 - Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다 바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells) - 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음 - 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신 시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침 - 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임 - 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지 - 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 → 화면 안쪽 칸에서만(안내) ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 · 어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum (여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry 197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은 저장 안 하고 「다시 열기」 로 버림. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
267 lines
9.2 KiB
TypeScript
267 lines
9.2 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 { confirmMerge, dialogKeys } from "./spreadsheet_actions";
|
|
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);
|
|
dialogKeys(overlay, done);
|
|
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"),
|
|
async () => {
|
|
const merged = isMerged(range);
|
|
if (!merged && !(await confirmMerge(ctx, range))) return;
|
|
ctx.dispatch({
|
|
종류: merged ? "병합풀기" : "병합",
|
|
시트: 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("MenuCut"), () => cutCopy("cut")],
|
|
[st("MenuCopy"), () => cutCopy("copy")],
|
|
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
|
[
|
|
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("MenuCut"), () => cutCopy("cut")],
|
|
[st("MenuCopy"), () => cutCopy("copy")],
|
|
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
|
[
|
|
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();
|
|
},
|
|
};
|
|
}
|