- 화면 시험 틀 가짜(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`) 잇는 자리 설명 추가
168 lines
5.8 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|