Files
Aislo/A00_Common/spreadsheet/spreadsheet_tabs.ts
T
eomsangdon fddaa4edfb feat(spreadsheet): E 화면 시험 진짜 A · B 엔진으로 다시 · 클립보드 HTML 견본 추가
- 화면 시험 틀 가짜(fakes/) 걷고 진짜 `applyCommand` · `createCalcEngine` · `createHistory` · 주소 · 숫자 형식으로 재시험
- 서식 · 병합 · 탭 이름 바꾸면 다른 시트 참조 식도 옮김 · 안↔안 클립보드 수식 상대 이동을 실제 엔진 계산값으로 확인
- 엑셀(<table> + mso 스타일 · colspan · x:str/x:num · 「수식 표시」) · 구글(google-sheets-html-origin · data-sheets-formula) 클립보드 HTML 견본 추가 · `parseClipboard` 로 붙여넣기 시험
- 이 PC 는 엑셀 앱 없음 · 구글 시트는 로그인 상태로 새 문서까지 열었으나 자동화 창이 OS 포커스를 못 받아(Document is not focused) 실제 클립보드 왕복은 못 함 — 견본 파일 시험으로 갈음
- toolbar · tabs · menu · clipboard 머리 주석에 D(`spreadsheet.ts`) 잇는 자리 설명 추가
2026-09-27 20:54:17 +09:00

168 lines
5.8 KiB
TypeScript

/* =============================================================================
* spreadsheet_tabs.ts (주인 E)
* 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 ·
* 끌어 옮기기 · 복사. 우클릭 메뉴는 `ui_template_context_menu.ts` 재사용.
* 이름 바꾸기 · 지우기 · 옮기기는 명령(`시트이름` · `시트지우기` · `시트옮기기` · `시트더하기`).
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `mountTabs(ctx)` 한 번 부르고 `root` 를 탭 자리(격자 아래)에
* 붙임. `ctx.showSheet` 로 활성 시트가 바뀌면(다른 길로도) `refresh()` 를 불러 다시 그림.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { emptySheet } from "./spreadsheet_commands";
import { st } from "./spreadsheet_text";
import type { Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_toolbar.css";
/** 없는 `s숫자` 하나 고름. */
function nextSheetId(book: Workbook): string {
const used = new Set(book.시트.map((s) => s.id));
let n = 1;
while (used.has(`s${n}`)) n++;
return `s${n}`;
}
/** 「시트2」 · 「시트2 (2)」 처럼 안 겹치는 이름. */
function nextSheetName(book: Workbook, base: string): string {
const used = new Set(book.시트.map((s) => s.이름));
if (!used.has(base)) return base;
let n = 2;
while (used.has(`${base} (${n})`)) n++;
return `${base} (${n})`;
}
export function mountTabs(ctx: SpreadsheetContext): PartHandle {
const menu = createMapContextMenu("ss");
const root = el("div", { className: "ss-tabs", children: [menu.element] });
let dragging: string | null = null;
function tabEl(id: string): HTMLElement | null {
return root.querySelector<HTMLElement>(`[data-sheet-id="${CSS.escape(id)}"]`);
}
function render(): void {
root.replaceChildren(menu.element, ...ctx.book.시트.map(renderTab), addButton());
}
function renderTab(sheet: Sheet): HTMLElement {
const tab = el("div", {
className: `ss-tabs__tab${sheet.id === ctx.book.활성 ? " is-active" : ""}`,
text: sheet.이름,
attrs: { "data-sheet-id": sheet.id },
});
tab.draggable = true;
tab.addEventListener("click", () => ctx.showSheet(sheet.id));
tab.addEventListener("dblclick", () => beginRename(sheet));
tab.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
const box = root.getBoundingClientRect();
openMenu(sheet, ev.clientX - box.left, ev.clientY - box.top);
});
tab.addEventListener("dragstart", (ev) => {
dragging = sheet.id;
ev.dataTransfer?.setData("text/plain", sheet.id);
});
tab.addEventListener("dragover", (ev) => {
if (!dragging || dragging === sheet.id) return;
ev.preventDefault();
tab.classList.add("is-dragover");
});
tab.addEventListener("dragleave", () => tab.classList.remove("is-dragover"));
tab.addEventListener("drop", (ev) => {
ev.preventDefault();
tab.classList.remove("is-dragover");
if (!dragging || dragging === sheet.id) return;
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id);
ctx.dispatch({ 종류: "시트옮기기", 시트: dragging, 자리 });
dragging = null;
});
return tab;
}
function beginRename(sheet: Sheet): void {
const tab = tabEl(sheet.id);
if (!tab) return;
const input = el("input", { attrs: { value: sheet.이름 } }) as HTMLInputElement;
tab.replaceChildren(input);
input.focus();
input.select();
const commit = (): void => {
const name = input.value.trim();
if (name && name !== sheet.이름)
ctx.dispatch({ 종류: "시트이름", 시트: sheet.id, 이름: name });
else render();
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") input.blur();
if (ev.key === "Escape") {
input.removeEventListener("blur", commit);
render();
}
});
input.addEventListener("blur", commit);
}
function openMenu(sheet: Sheet, x: number, y: number): void {
const items: MapContextMenuItem[] = [
[st("SheetRename"), () => beginRename(sheet)],
[
st("SheetDuplicate"),
() => {
const copy: Sheet = {
...structuredClone(sheet),
id: nextSheetId(ctx.book),
이름: nextSheetName(ctx.book, sheet.이름),
};
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id) + 1;
ctx.dispatch({ 종류: "시트더하기", 시트: copy, 자리 });
},
],
[
st("SheetDelete"),
async () => {
if (ctx.book.시트.length <= 1) {
showToast(st("SheetDeleteLastError"), "error");
return;
}
if (await showConfirmDialog(st("SheetDeleteConfirm"))) {
ctx.dispatch({ 종류: "시트지우기", 시트: sheet.id });
}
},
],
];
menu.open(x, y, items);
}
function addButton(): HTMLElement {
const btn = el("button", {
className: "ss-tabs__add",
text: "+",
attrs: { type: "button", title: st("SheetAdd") },
});
btn.addEventListener("click", () => {
const name = nextSheetName(ctx.book, "Sheet");
const sheet = emptySheet(nextSheetId(ctx.book), name);
ctx.dispatch({ 종류: "시트더하기", 시트: sheet, 자리: ctx.book.시트.length });
});
return btn;
}
render();
return {
root,
refresh: render,
destroy() {
menu.close();
root.remove();
},
};
}