Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5

This commit is contained in:
2026-09-27 20:28:14 +09:00
3 changed files with 202 additions and 0 deletions
@@ -0,0 +1,139 @@
/* =============================================================================
* spreadsheet_comments.ts (2단계 · 주인 sub_laptop_1)
* 메모(풍선 설명) · 칸 위쪽 빨간 세모 표시. 저장 자리 — 브레인 답(2026-09-27):
* `Sheet.comments?: Record<string, string>` (열쇠 = A1 주소 · 값 = 메모 글 · 빈 글이면 지움).
* 이 칸은 sub3 가 0 계약(spreadsheet_types.ts)에 보태는 중 — 여기서는 고치지 않고 모양만 가정
* (`SheetWithComments` 로 좁혀 씀 · sub3 커밋이 오면 구조가 그대로 맞음).
*
* 문서를 바꾸는 길은 명령뿐(view_types.ts 머리) — 「메모」 전용 명령이 아직 없어 `시트통째`
* (있는 시트를 통째로 바꿔 넣는 명령)로 얹음. 메모 전용 명령이 생기면 이 파일의 `applyComments`
* 한 곳만 고치면 됨.
*
* 행 · 열 넣기 · 지우기 때 메모 주소 옮김 — 지금은 안 함(사각지대로 남김). A 가
* `spreadsheet_refshift.ts` 를 완성하면 그 파일의 참조 옮김 함수(예: `shiftAddress` 류)를
* 이 파일 `remapComments` 자리에 불러 A1 주소들을 옮기게 잇기(지우는 행 · 열에 있던 메모는
* 참조 지움과 같은 규칙으로 버림). 지금은 `remapComments` 를 D · A 가 채울 자리로 비워 둠.
*
* 잇는 법(D · sub7 · C 격자) — `attachComments(ctx)` 를 한 번 붙이고:
* 1) C 가 칸을 그릴 때 `hasComment(sheet, r, c)` 가 참이면 칸 위 오른쪽에 빨간 세모(4~6px)를 그림.
* 2) 그 칸에 마우스가 올라가면(hover) `showBalloon(r, c)` · 벗어나면 `hideBalloon()`.
* 3) 우클릭 메뉴 [메모 삽입 · 메모 편집 · 메모 삭제] 는 `setComment` · `deleteComment` 를 부름.
* ========================================================================== */
import type { Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import { el } from "@ui/ui_template_elements";
/** 0 계약이 아직 안 실은 칸 — sub3 커밋 뒤엔 진짜 `Sheet` 가 이 모양을 그대로 가짐. */
export interface SheetWithComments extends Sheet {
comments?: Record<string, string>;
}
const a1 = (r: number, c: number): string => {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
};
/** 자리 이동(행열 넣기 · 지우기) 뒤 메모 주소를 옮김 — A `spreadsheet_refshift` 완성 뒤 채울 자리.
* `shift` 는 옛 주소 → 새 주소(지워진 자리면 null). 지금은 호출부가 없어 안 씀(사각지대 기록용). */
export function remapComments(
comments: Record<string, string>,
shift: (old: { r: number; c: number }) => { r: number; c: number } | null,
): Record<string, string> {
const next: Record<string, string> = {};
for (const [key, text] of Object.entries(comments)) {
const at = parseA1(key);
const moved = shift(at);
if (moved) next[a1(moved.r, moved.c)] = text;
}
return next;
}
function parseA1(key: string): { r: number; c: number } {
const m = /^([A-Z]+)(\d+)$/.exec(key);
if (!m) return { r: 0, c: 0 };
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { r: Number(m[2]) - 1, c: col - 1 };
}
export function getComment(sheet: Sheet, r: number, c: number): string | null {
const comments = (sheet as SheetWithComments).comments;
return comments?.[a1(r, c)] ?? null;
}
export function hasComment(sheet: Sheet, r: number, c: number): boolean {
return getComment(sheet, r, c) !== null;
}
export function listComments(sheet: Sheet): { r: number; c: number; 글: string }[] {
const comments = (sheet as SheetWithComments).comments ?? {};
return Object.entries(comments).map(([key, 글]) => ({ ...parseA1(key), 글 }));
}
/** `시트통째` 명령으로 메모 칸만 바꿔 넣음 — 그 밖 시트 내용은 그대로. */
function applyComments(ctx: SpreadsheetContext, next: Record<string, string>): void {
const sheet = ctx.sheet() as SheetWithComments;
const cleaned: Record<string, string> = {};
for (const [key, text] of Object.entries(next)) if (text) cleaned[key] = text;
const nextSheet: SheetWithComments = { ...sheet, comments: cleaned };
ctx.dispatch({ 종류: "시트통째", 시트: nextSheet });
}
/** 글이 비면 메모를 지움(값과 같은 규칙 — `spreadsheet_types.ts` 「빈 칸 안 적음」). */
export function setComment(ctx: SpreadsheetContext, r: number, c: number, text: string): void {
const sheet = ctx.sheet() as SheetWithComments;
const next = { ...(sheet.comments ?? {}) };
if (text) next[a1(r, c)] = text;
else delete next[a1(r, c)];
applyComments(ctx, next);
}
export function deleteComment(ctx: SpreadsheetContext, r: number, c: number): void {
setComment(ctx, r, c, "");
}
export interface CommentsHandle extends PartHandle {
showBalloon(r: number, c: number): void;
hideBalloon(): void;
}
/** 풍선 뿌리 하나를 만들어 `ctx.root` 에 붙임 — 세모 그리기 자체는 C(격자)가 `hasComment` 로 판단해서 함. */
export function attachComments(ctx: SpreadsheetContext): CommentsHandle {
const balloon = el("div", { className: "spreadsheet-comment-balloon", attrs: { hidden: "" } });
ctx.root.append(balloon);
return {
root: balloon,
showBalloon(r: number, c: number): void {
const text = getComment(ctx.sheet(), r, c);
if (!text) return void this.hideBalloon();
balloon.textContent = text;
balloon.removeAttribute("hidden");
const box = ctx.grid.cellBox(r, c);
balloon.style.left = `${box.x + box.w}px`;
balloon.style.top = `${box.y}px`;
},
hideBalloon(): void {
balloon.setAttribute("hidden", "");
},
refresh(): void {
/* 문서가 바뀌면 지금 보이는 풍선 글도 새로 — 열린 풍선이 없으면 할 일 없음 */
},
destroy(): void {
balloon.remove();
},
};
}
/** 시트 전체 메모 개수(시험 · 실무 대조용) */
export function countComments(book: Workbook, sheetId: string): number {
const sheet = book.시트.find((s) => s.id === sheetId);
return sheet ? Object.keys((sheet as SheetWithComments).comments ?? {}).length : 0;
}
@@ -0,0 +1,59 @@
/* =============================================================================
* test_stage2_comments.ts — spreadsheet_comments 시험. 돌리기: npx tsx <이 파일> (el 때문에 @ui 필요).
* ========================================================================== */
import {
countComments,
deleteComment,
getComment,
hasComment,
listComments,
remapComments,
setComment,
type SheetWithComments,
} from "../../../A00_Common/spreadsheet/spreadsheet_comments.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
const sheet = makeSheet("s1", { A1: v(1), B2: v(2) }) as SheetWithComments;
sheet.comments = { A1: "돌뒷길이+직고*0.1" };
const book = makeBook([sheet]);
assertEqual(getComment(sheet, 0, 0), "돌뒷길이+직고*0.1", "getComment 있음");
assertEqual(getComment(sheet, 1, 1), null, "getComment 없으면 null");
assertEqual(hasComment(sheet, 0, 0), true, "hasComment 참");
assertEqual(hasComment(sheet, 5, 5), false, "hasComment 거짓");
assertEqual(countComments(book, "s1"), 1, "countComments");
assertEqual(listComments(sheet), [{ r: 0, c: 0, 글: "돌뒷길이+직고*0.1" }], "listComments");
const ctx = makeCtx(book, "s1");
setComment(ctx, 1, 1, "새 메모");
const updated = ctx.applied.at(-1);
assertEqual(updated?.종류, "시트통째", "setComment 는 시트통째 명령으로 나감");
assertEqual(
(updated as { 시트: SheetWithComments }).시트.comments,
{ A1: "돌뒷길이+직고*0.1", B2: "새 메모" },
"메모 칸에 더해짐(다른 메모는 그대로)",
);
setComment(ctx, 0, 0, "");
const cleared = ctx.applied.at(-1);
assertEqual(
(cleared as { 시트: SheetWithComments }).시트.comments,
{ B2: "새 메모" },
"빈 글로 지움(안 적음)",
);
const moved = remapComments({ A1: "위쪽", C3: "아래쪽" }, (at) =>
at.r === 0 ? { r: at.r + 1, c: at.c } : at,
);
assertEqual(moved, { A2: "위쪽", C3: "아래쪽" }, "remapComments 옮김 함수대로 자리를 바꿈");
const dropped = remapComments({ A1: "지워질 것" }, () => null);
assertEqual(dropped, {}, "remapComments — shift 가 null 이면 버림");
deleteComment(ctx, 1, 1);
const afterDelete = (ctx.applied.at(-1) as { 시트: SheetWithComments }).시트.comments;
assertEqual(afterDelete?.B2, undefined, "deleteComment 로 지워짐");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_comments 시험 끝");
@@ -90,6 +90,10 @@ export function makeCtx(
sheet.열 = sheet.열 ?? {};
for (const c of cmd.열)
sheet.열[colLetters(c)] = { ...sheet.열[colLetters(c)], 폭: cmd.폭 ?? undefined };
} else if (cmd.종류 === "시트통째") {
const idx = book.시트.findIndex((s) => s.id === cmd.시트.id);
if (idx >= 0) book.시트[idx] = cmd.시트;
if (cmd.서식) book.서식 = cmd.서식;
}
};