feat(spreadsheet): E 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 구현

- 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기
- 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기
- 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용)
- 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML)
- E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가
This commit is contained in:
2026-09-27 20:33:26 +09:00
parent 26fd4fb72e
commit bfd7123829
14 changed files with 1937 additions and 37 deletions
+343 -16
View File
@@ -2,32 +2,359 @@
* spreadsheet_clipboard.ts (주인 E)
* 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀).
* 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라 붙이기(안쪽) = `옮기기` 명령 ·
* 나머지 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 병합 명령 묶음.
* 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김. 0 계약 머리 — 몸은 E 가 채움.
* 나머지 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령 묶음.
* 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김.
* ========================================================================== */
import type { CellAddress, CellRange, ClipBlock, Workbook } from "./spreadsheet_types";
import { showToast } from "@ui/ui_template_elements";
import { colName, parseRange, toA1 } from "./spreadsheet_address";
import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
import { st } from "./spreadsheet_text";
import type {
BorderLine,
Cell,
CellAddress,
CellInput,
CellRange,
CellStyle,
ClipBlock,
ClipCell,
Command,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const todo = (): never => {
throw new Error("spreadsheet_clipboard: 아직 없음(E)");
/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */
const BORDER_CSS: Record<BorderLine, string> = {
hair: "dotted",
thin: "solid",
medium: "solid",
thick: "solid",
double: "double",
dotted: "dotted",
dashed: "dashed",
};
/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */
export function attachClipboard(_ctx: SpreadsheetContext): PartHandle {
return todo();
function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
const cell = sheet.칸[toA1(r, c)];
const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0;
return book.서식[idx] ?? {};
}
/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이) */
export function parseClipboard(_html: string, _text: string, _at: CellAddress): ClipBlock | null {
return todo();
function styleToCss(style: CellStyle): string {
const parts: string[] = [];
if (style.글꼴) parts.push(`font-family:${style.글꼴}`);
if (style.크기) parts.push(`font-size:${style.크기}pt`);
if (style.굵게) parts.push("font-weight:bold");
if (style.기울임) parts.push("font-style:italic");
const deco = [style.밑줄 && "underline", style.취소선 && "line-through"]
.filter(Boolean)
.join(" ");
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") {
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
}
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
const s = style.테두리?.[side];
if (!s) return;
const prop = {
위: "border-top",
아래: "border-bottom",
왼: "border-left",
오른: "border-right",
}[side];
parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
});
}
return parts.join(";");
}
/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON) */
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
const { r0, c0, r1, c1 } = range;
const 칸: Record<string, ClipCell> = {};
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const cell = sheet.칸[toA1(r, c)];
if (!cell) continue;
const clip: ClipCell = {};
if (cell.값 !== undefined) clip.값 = cell.값;
if (cell.식 !== undefined) clip.식 = cell.식;
if (cell.서식 !== undefined) clip.서식 = book.서식[cell.서식];
if (Object.keys(clip).length) 칸[`${r - r0},${c - c0}`] = clip;
}
}
const 병합 = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.filter((m): m is CellRange => !!m && m.r0 >= r0 && m.c0 >= c0 && m.r1 <= r1 && m.c1 <= c1)
.map((m) => ({ r0: m.r0 - r0, c0: m.c0 - c0, r1: m.r1 - r0, c1: m.c1 - c0 }));
return { rows: r1 - r0 + 1, cols: c1 - c0 + 1, 칸, 병합, 원점: { r: r0, c: c0 }, 출처: "aislo" };
}
/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON). */
export function blockToClipboard(
_book: Workbook,
_sheet: string,
_range: CellRange,
book: Workbook,
sheetId: string,
range: CellRange,
valueAt: (r: number, c: number) => Scalar,
): { html: string; text: string } {
return todo();
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return { html: "", text: "" };
const { r0, c0, r1, c1 } = range;
const aisloBlock = buildAisloBlock(book, sheet, range);
const rowsHtml: string[] = [];
const tsvRows: string[] = [];
const skip = new Set<string>();
for (let r = r0; r <= r1; r++) {
const cellsHtml: string[] = [];
const tsvCells: string[] = [];
for (let c = c0; c <= c1; c++) {
const key = `${r},${c}`;
if (skip.has(key)) continue;
const style = styleAt(book, sheet, r, c);
const text = formatValue(valueAt(r, c), style.형식).글;
tsvCells.push(text.replace(/\t/g, " "));
let span = "";
const merge = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.find((m): m is CellRange => !!m && m.r0 === r && m.c0 === c && m.r1 <= r1 && m.c1 <= c1);
if (merge) {
const rs = merge.r1 - merge.r0 + 1;
const cs = merge.c1 - merge.c0 + 1;
if (rs > 1) span += ` rowspan="${rs}"`;
if (cs > 1) span += ` colspan="${cs}"`;
for (let dr = 0; dr < rs; dr++)
for (let dc = 0; dc < cs; dc++) skip.add(`${r + dr},${c + dc}`);
}
cellsHtml.push(
`<td${span} style="${escapeHtml(styleToCss(style))}">${escapeHtml(text)}</td>`,
);
}
rowsHtml.push(`<tr>${cellsHtml.join("")}</tr>`);
tsvRows.push(tsvCells.join("\t"));
}
const table = `<table>${rowsHtml.join("")}</table>`;
const html = `<!--aislo:${encodeURIComponent(JSON.stringify(aisloBlock))}-->${table}`;
return { html, text: tsvRows.join("\r\n") };
}
/* ── 밖 → 안 ─────────────────────────────────────────────────────────────── */
/** 「1,234.56」 · 「TRUE」 는 값으로 · 나머지는 글로. */
function coerceValue(text: string): CellInput {
const t = text.trim();
if (/^[+-]?\d[\d,]*(\.\d+)?$/.test(t)) {
const n = Number(t.replace(/,/g, ""));
if (Number.isFinite(n)) return n;
}
if (/^(TRUE|FALSE)$/i.test(t)) return t.toUpperCase() === "TRUE";
return text;
}
/** 「=」 뒤에 글이 있고 식으로 보이면 식 — 엑셀 「수식 표시」 복사 대응(「=」 한 글자는 글). */
function cellFromText(text: string): ClipCell {
if (text.startsWith("=") && text.length > 1) return { 식: text.slice(1) };
return { 값: coerceValue(text) };
}
interface TableWalk {
rows: number;
cols: number;
owner: Map<string, HTMLTableCellElement>;
merges: CellRange[];
}
function walkTable(table: HTMLTableElement): TableWalk {
const occupied = new Set<string>();
const owner = new Map<string, HTMLTableCellElement>();
const merges: CellRange[] = [];
const rows = Array.from(table.rows);
rows.forEach((row, r) => {
let c = 0;
Array.from(row.cells).forEach((cellEl) => {
while (occupied.has(`${r},${c}`)) c++;
const rowspan = cellEl.rowSpan || 1;
const colspan = cellEl.colSpan || 1;
owner.set(`${r},${c}`, cellEl);
for (let dr = 0; dr < rowspan; dr++) {
for (let dc = 0; dc < colspan; dc++) occupied.add(`${r + dr},${c + dc}`);
}
if (rowspan > 1 || colspan > 1) {
merges.push({ r0: r, c0: c, r1: r + rowspan - 1, c1: c + colspan - 1 });
}
c += colspan;
});
});
let cols = 0;
for (const key of occupied) {
const c = Number(key.split(",")[1]);
if (c + 1 > cols) cols = c + 1;
}
return { rows: rows.length, cols, owner, merges };
}
function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null {
const doc = new DOMParser().parseFromString(html, "text/html");
const table = doc.querySelector("table");
if (!table) return null;
const walk = walkTable(table);
const sheetsMode = /data-sheets-formula/.test(html);
const 칸: Record<string, ClipCell> = {};
for (const [key, cellEl] of walk.owner) {
const [r, c] = key.split(",").map(Number);
const text = cellEl.textContent ?? "";
const formula = sheetsMode ? cellEl.getAttribute("data-sheets-formula") : null;
if (formula) {
const target = { r: at.r + r, c: at.c + c };
칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) };
continue;
}
if (!text) continue;
칸[key] = cellFromText(text);
}
return {
rows: walk.rows,
cols: walk.cols,
칸,
병합: walk.merges,
원점: null,
출처: sheetsMode ? "google" : "excel",
};
}
function parseTsv(text: string): ClipBlock {
const lines = text.replace(/\r\n/g, "\n").split("\n");
while (lines.length && lines[lines.length - 1] === "") lines.pop();
const grid = lines.map((line) => line.split("\t"));
let cols = 0;
for (const row of grid) if (row.length > cols) cols = row.length;
const 칸: Record<string, ClipCell> = {};
grid.forEach((row, r) => {
row.forEach((text, c) => {
if (text === "") return;
칸[`${r},${c}`] = cellFromText(text);
});
});
return { rows: grid.length, cols, 칸, 병합: [], 원점: null, 출처: "text" };
}
/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이). */
export function parseClipboard(html: string, text: string, at: CellAddress): ClipBlock | null {
const aislo = /<!--aislo:([^>]*)-->/.exec(html);
if (aislo) {
try {
return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock;
} catch {
// 손상됐으면 아래 일반 경로로 폴백.
}
}
if (/<table/i.test(html)) return parseHtmlTable(html, at);
if (text) return parseTsv(text);
return null;
}
/* ── 화면 붙임 — copy · cut · paste 사건 ────────────────────────────────── */
function activeRange(ctx: SpreadsheetContext): CellRange {
return (
ctx.selection.범위[0] ?? {
r0: ctx.selection.활성.r,
c0: ctx.selection.활성.c,
r1: ctx.selection.활성.r,
c1: ctx.selection.활성.c,
}
);
}
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
const shift = block.원점 ? { dr: at.r - block.원점.r, dc: at.c - block.원점.c } : null;
const 칸: Record<string, Cell | null> = {};
const styleCmds: Command[] = [];
for (const [key, clip] of Object.entries(block.칸)) {
const [lr, lc] = key.split(",").map(Number);
const r = at.r + lr;
const c = at.c + lc;
const cell: Cell = {};
if (clip.값 !== undefined) cell.값 = clip.값;
if (clip.식 !== undefined) cell.식 = shift ? moveFormula(clip.식, shift.dr, shift.dc) : clip.식;
칸[toA1(r, c)] = Object.keys(cell).length ? cell : null;
if (clip.서식) {
styleCmds.push({
종류: "서식",
시트: ctx.selection.시트,
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: clip.서식,
});
}
}
const commands: Command[] = [{ 종류: "칸", 시트: ctx.selection.시트, 칸 }, ...styleCmds];
for (const m of block.병합) {
commands.push({
종류: "병합",
시트: ctx.selection.시트,
범위: { r0: at.r + m.r0, c0: at.c + m.c0, r1: at.r + m.r1, c1: at.c + m.c1 },
});
}
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
}
/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */
export function attachClipboard(ctx: SpreadsheetContext): PartHandle {
function onCopy(ev: ClipboardEvent): void {
if (ctx.editor.editing()) return;
const range = activeRange(ctx);
const { html, text } = blockToClipboard(ctx.book, ctx.selection.시트, range, (r, c) =>
ctx.engine.value(ctx.selection.시트, r, c),
);
ev.clipboardData?.setData("text/html", html);
ev.clipboardData?.setData("text/plain", text);
ev.preventDefault();
}
function onCut(ev: ClipboardEvent): void {
if (ctx.editor.editing() || ctx.readOnly) return;
onCopy(ev);
ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [activeRange(ctx)] });
}
function onPaste(ev: ClipboardEvent): void {
if (ctx.editor.editing() || ctx.readOnly) return;
const html = ev.clipboardData?.getData("text/html") ?? "";
const text = ev.clipboardData?.getData("text/plain") ?? "";
const at = ctx.selection.활성;
const block = parseClipboard(html, text, at);
if (!block) {
showToast(st("ClipboardPasteFailed"), "error");
return;
}
ev.preventDefault();
pasteBlock(ctx, block, at);
}
ctx.root.addEventListener("copy", onCopy);
ctx.root.addEventListener("cut", onCut);
ctx.root.addEventListener("paste", onPaste);
return {
root: null,
refresh() {},
destroy() {
ctx.root.removeEventListener("copy", onCopy);
ctx.root.removeEventListener("cut", onCut);
ctx.root.removeEventListener("paste", onPaste);
},
};
}
+200 -6
View File
@@ -2,16 +2,210 @@
* spreadsheet_menu.ts (주인 E)
* 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) ·
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기.
* 0 계약 머리 — 몸은 E 가 채움.
* `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { showToast } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const todo = (): never => {
throw new Error("spreadsheet_menu: 아직 없음(E)");
};
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 {
return todo();
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);
}
function cellMenuItems(range: CellRange): MapContextMenuItem[] {
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 },
),
],
];
}
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));
}
}
ctx.root.addEventListener("contextmenu", onContextMenu);
return {
root: menu.element,
refresh() {},
destroy() {
ctx.root.removeEventListener("contextmenu", onContextMenu);
menu.close();
menu.element.remove();
},
};
}
+155 -7
View File
@@ -1,16 +1,164 @@
/* =============================================================================
* spreadsheet_tabs.ts (주인 E)
* 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 · 끌어 옮기기 · 복사.
* 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 ·
* 끌어 옮기기 · 복사. 우클릭 메뉴는 `ui_template_context_menu.ts` 재사용.
* 이름 바꾸기 · 지우기 · 옮기기는 명령(`시트이름` · `시트지우기` · `시트옮기기` · `시트더하기`).
* 0 계약 머리 — 몸은 E 가 채움.
* ========================================================================== */
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";
const todo = (): never => {
throw new Error("spreadsheet_tabs: 아직 없음(E)");
};
import "./spreadsheet_toolbar.css";
export function mountTabs(_ctx: SpreadsheetContext): PartHandle {
return todo();
/** 없는 `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();
},
};
}
@@ -0,0 +1,86 @@
/* =============================================================================
* spreadsheet_text.ts (주인 E)
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어].
* `ui_template_sheet_text.ts` 와 같은 방식(`st(key)`). 0 계약 밖(E 만 씀).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Undo: ["되돌리기", "Undo"],
Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"],
Bold: ["굵게", "Bold"],
Italic: ["기울임", "Italic"],
Underline: ["밑줄", "Underline"],
Strike: ["취소선", "Strikethrough"],
TextColor: ["글자색", "Text color"],
FillColor: ["채움색", "Fill color"],
ColorClear: ["지움", "Clear"],
HAlign: ["가로 정렬", "Horizontal align"],
VAlign: ["세로 정렬", "Vertical align"],
Align_general: ["일반", "General"],
Align_left: ["왼쪽", "Left"],
Align_center: ["가운데", "Center"],
Align_right: ["오른쪽", "Right"],
Align_distributed: ["균등 분할", "Distributed"],
Align_centerContinuous: ["선택 영역 가운데", "Center across"],
VAlign_top: ["위", "Top"],
VAlign_center: ["가운데", "Middle"],
VAlign_bottom: ["아래", "Bottom"],
Wrap: ["줄 바꿈", "Wrap text"],
Merge: ["병합", "Merge"],
Unmerge: ["병합 해제", "Unmerge"],
Border: ["테두리", "Border"],
Border_none: ["없음", "None"],
Border_all: ["모두", "All"],
Border_outer: ["바깥", "Outer"],
Border_top: ["위", "Top"],
Border_bottom: ["아래", "Bottom"],
Border_left: ["왼", "Left"],
Border_right: ["오른", "Right"],
BorderLine: ["선", "Line"],
BorderColor: ["색", "Color"],
NumberFormat: ["숫자 형식", "Number format"],
NumberFormat_general: ["일반", "General"],
NumberFormat_integer: ["정수 0", "Integer"],
NumberFormat_decimal2: ["소수 0.00", "Decimal 0.00"],
NumberFormat_thousands: ["천 단위 #,##0", "Thousands"],
NumberFormat_thousands2: ["천 단위 #,##0.00", "Thousands 0.00"],
NumberFormat_percent: ["백분율 0%", "Percent"],
NumberFormat_accounting: ["회계", "Accounting"],
NumberFormat_text: ["글자 @", "Text"],
NumberFormat_hint: [
"이름 골라 쓰거나 엑셀 형식 코드를 바로 침",
"Pick a preset or type an Excel format code",
],
SheetAdd: ["시트 더하기", "Add sheet"],
SheetRename: ["이름 바꾸기", "Rename"],
SheetDelete: ["지우기", "Delete"],
SheetDuplicate: ["복사", "Duplicate"],
SheetRenamePrompt: ["새 시트 이름", "New sheet name"],
SheetDeleteConfirm: [
"이 시트를 지울까요? 되돌릴 수 없습니다.",
"Delete this sheet? This cannot be undone.",
],
SheetDeleteLastError: ["마지막 시트는 지울 수 없음", "Cannot delete the last sheet"],
MenuCut: ["잘라내기", "Cut"],
MenuCopy: ["복사", "Copy"],
MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"],
MenuInsertRow: ["행 넣기", "Insert row"],
MenuDeleteRow: ["행 지우기", "Delete row"],
MenuInsertCol: ["열 넣기", "Insert column"],
MenuDeleteCol: ["열 지우기", "Delete column"],
MenuHide: ["숨기기", "Hide"],
MenuUnhide: ["숨김 취소", "Unhide"],
MenuClearContent: ["내용 지우기", "Clear content"],
MenuClearFormat: ["서식 지우기", "Clear format"],
MenuMerge: ["병합", "Merge"],
MenuUnmerge: ["병합 해제", "Unmerge"],
ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"],
} as const;
export function st(key: keyof typeof TEXT): string {
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
}
@@ -0,0 +1,217 @@
/* =============================================================================
* spreadsheet_toolbar.css (주인 E)
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 안내 토스트 — 화면 스타일 한 벌.
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
* ========================================================================== */
.ss-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-toolbar__group {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
padding-right: var(--spacing-8);
border-right: 1px solid var(--color-border);
}
.ss-toolbar__group:last-child {
border-right: 0;
}
.ss-toolbar .ui-btn {
padding: 4px 8px;
font-size: 0.82rem;
}
.ss-toolbar .ui-btn.is-on {
background: var(--color-mist-violet);
box-shadow: inset 0 0 0 1px var(--color-royal-amethyst);
}
.ss-toolbar__select {
height: 28px;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.8rem;
}
.ss-toolbar__input {
height: 28px;
width: 72px;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.8rem;
}
.ss-toolbar__input--font {
width: 96px;
}
.ss-toolbar__input--size {
width: 48px;
}
.ss-toolbar__input--numfmt {
width: 140px;
}
.ss-toolbar__color {
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
}
.ss-toolbar__clear {
padding: 0 4px;
border: 0;
background: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
}
.ss-toolbar__popover {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 180px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-toolbar__popover[hidden] {
display: none;
}
.ss-toolbar__popover-row {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.ss-toolbar__popover-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-4);
}
/* ── 시트 탭 ─────────────────────────────────────────────────────────────── */
.ss-tabs {
position: relative;
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-tabs__tab {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: 4px 10px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--color-paper);
color: var(--color-text-secondary);
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.ss-tabs__tab.is-active {
background: var(--color-canvas);
color: var(--color-text-body);
font-weight: 600;
}
.ss-tabs__tab.is-dragover {
box-shadow: inset 2px 0 0 var(--color-accent);
}
.ss-tabs__tab input {
width: 96px;
border: 1px solid var(--color-focus-ring);
border-radius: var(--radius-sm);
font: inherit;
}
.ss-tabs__add {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-secondary);
cursor: pointer;
}
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */
.ss__context-menu {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 160px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss__context-menu[hidden] {
display: none;
}
.ss__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-body);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.ss__context-menu-item:hover {
background: var(--color-surface-sunken, var(--color-mist-violet));
}
.ss__context-menu-item.is-danger {
color: var(--color-danger);
}
.ss__context-menu-sep {
margin: 2px 4px;
border-top: 1px solid var(--color-border);
}
+363 -8
View File
@@ -1,17 +1,372 @@
/* =============================================================================
* spreadsheet_toolbar.ts (주인 E)
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 글자색 · 채움색 · 가로 · 세로 정렬 ·
* 줄 바꿈 · 테두리(위치 × 선) · 숫자 형식 · 병합 · 틀고정 · 눈금선. 모두 `ctx.dispatch` 명령으로.
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 ·
* 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식.
* 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림.
* 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null).
* 0 계약 머리 — 몸은 E 가 채움.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const todo = (): never => {
throw new Error("spreadsheet_toolbar: 아직 없음(E)");
};
import "./spreadsheet_toolbar.css";
export function mountToolbar(_ctx: SpreadsheetContext): PartHandle {
return todo();
type Side = "위" | "아래" | "왼" | "오른";
type BorderPreset = "none" | "all" | "outer" | "top" | "bottom" | "left" | "right";
const FONTS = [
"굴림",
"굴림체",
"맑은 고딕",
"나눔고딕",
"돋움",
"Arial",
"Calibri",
"Times New Roman",
];
const NUMFMT_PRESETS: [label: string, code: string][] = [
[st("NumberFormat_general"), ""],
[st("NumberFormat_integer"), "0"],
[st("NumberFormat_decimal2"), "0.00"],
[st("NumberFormat_thousands"), "#,##0"],
[st("NumberFormat_thousands2"), "#,##0.00"],
[st("NumberFormat_percent"), "0%"],
[st("NumberFormat_accounting"), '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'],
[st("NumberFormat_text"), "@"],
];
const BORDER_LINES: BorderLine[] = [
"hair",
"thin",
"medium",
"thick",
"double",
"dotted",
"dashed",
];
/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 그다음 기본(0). */
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
const sheet = ctx.sheet();
const cell = sheet.칸[toA1(r, c)];
const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0;
return ctx.book.서식[idx] ?? {};
}
export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
const root = el("div", { className: "ss-toolbar" });
const group = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "ss-toolbar__group", children });
const patch = (change: { [K in keyof CellStyle]?: CellStyle[K] | null }): void => {
ctx.dispatch({
종류: "서식",
시트: ctx.selection.시트,
범위: ctx.selection.범위,
바꿀: change,
});
};
// ── 되돌리기 · 다시 ────────────────────────────────────────────────────
const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() });
undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
redoBtn.title = st("Redo");
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
const fontInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--font",
attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") },
}) as HTMLInputElement;
const fontList = el("datalist", {
attrs: { id: "ss-toolbar-fonts" },
children: FONTS.map((name) => el("option", { attrs: { value: name } })),
});
fontInput.append(fontList);
const commitFont = (): void => patch({ 글꼴: fontInput.value.trim() || null });
fontInput.addEventListener("change", commitFont);
const sizeInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--size",
attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") },
}) as HTMLInputElement;
sizeInput.addEventListener("change", () => {
const n = Number(sizeInput.value);
patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? n : null });
});
// ── 굵게 · 기울임 · 밑줄 · 취소선 ──────────────────────────────────────
const boldBtn = createButton({ label: "B", variant: "ghost" });
boldBtn.title = st("Bold");
const italicBtn = createButton({ label: "I", variant: "ghost" });
italicBtn.title = st("Italic");
const underlineBtn = createButton({ label: "U", variant: "ghost" });
underlineBtn.title = st("Underline");
const strikeBtn = createButton({ label: "S", variant: "ghost" });
strikeBtn.title = st("Strike");
const toggles: [HTMLButtonElement, keyof CellStyle][] = [
[boldBtn, "굵게"],
[italicBtn, "기울임"],
[underlineBtn, "밑줄"],
[strikeBtn, "취소선"],
];
for (const [btn, key] of toggles) {
btn.addEventListener("click", () => patch({ [key]: !btn.classList.contains("is-on") }));
}
// ── 글자색 · 채움색 ────────────────────────────────────────────────────
const textColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" },
}) as HTMLInputElement;
textColor.addEventListener("change", () => patch({ 글자색: textColor.value }));
const textColorClear = el("button", {
className: "ss-toolbar__clear",
text: "✕",
attrs: { type: "button", title: st("ColorClear") },
});
textColorClear.addEventListener("click", () => patch({ 글자색: null }));
const fillColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" },
}) as HTMLInputElement;
fillColor.addEventListener("change", () => patch({ 채움: fillColor.value }));
const fillColorClear = el("button", {
className: "ss-toolbar__clear",
text: "✕",
attrs: { type: "button", title: st("ColorClear") },
});
fillColorClear.addEventListener("click", () => patch({ 채움: null }));
// ── 가로 · 세로 정렬 · 줄 바꿈 ─────────────────────────────────────────
const HALIGN_LABEL = {
general: st("Align_general"),
left: st("Align_left"),
center: st("Align_center"),
right: st("Align_right"),
distributed: st("Align_distributed"),
centerContinuous: st("Align_centerContinuous"),
} as const;
const VALIGN_LABEL = {
top: st("VAlign_top"),
center: st("VAlign_center"),
bottom: st("VAlign_bottom"),
} as const;
const hAlignSelect = el("select", {
className: "ss-toolbar__select",
attrs: { "aria-label": st("HAlign") },
children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) =>
el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }),
),
}) as HTMLSelectElement;
hAlignSelect.addEventListener("change", () =>
patch({ 가로: hAlignSelect.value as CellStyle["가로"] }),
);
const vAlignSelect = el("select", {
className: "ss-toolbar__select",
attrs: { "aria-label": st("VAlign") },
children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) =>
el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }),
),
}) as HTMLSelectElement;
vAlignSelect.addEventListener("change", () =>
patch({ 세로: vAlignSelect.value as CellStyle["세로"] }),
);
const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" });
wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") }));
// ── 병합 ───────────────────────────────────────────────────────────────
const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" });
mergeBtn.addEventListener("click", () => {
const range = ctx.selection.범위[0];
if (!range) return;
const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range });
});
// ── 테두리 ───────────────────────────────────────────────────────────
const borderBtn = createButton({ label: st("Border"), variant: "ghost" });
const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } });
const lineSelect = el("select", {
className: "ss-toolbar__select",
children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })),
}) as HTMLSelectElement;
const lineColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", value: "#000000" },
}) as HTMLInputElement;
const presetsRow = el("div", { className: "ss-toolbar__popover-presets" });
const presets: [BorderPreset, string][] = [
["none", st("Border_none")],
["all", st("Border_all")],
["outer", st("Border_outer")],
["top", st("Border_top")],
["bottom", st("Border_bottom")],
["left", st("Border_left")],
["right", st("Border_right")],
];
for (const [preset, label] of presets) {
const b = createButton({
label,
variant: "ghost",
onClick: () => {
applyBorderPreset(preset);
borderPop.hidden = true;
},
});
presetsRow.append(b);
}
borderPop.append(
el("div", {
className: "ss-toolbar__popover-row",
children: [el("span", { text: st("BorderLine") }), lineSelect],
}),
el("div", {
className: "ss-toolbar__popover-row",
children: [el("span", { text: st("BorderColor") }), lineColor],
}),
presetsRow,
);
borderBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
borderPop.hidden = !borderPop.hidden;
});
borderPop.addEventListener("click", (ev) => ev.stopPropagation());
const closeBorderPop = (): void => {
borderPop.hidden = true;
};
document.addEventListener("click", closeBorderPop);
function cellsFor(
range: CellRange,
edge: BorderPreset,
): { r: number; c: number; sides: Side[] }[] {
const { r0, c0, r1, c1 } = range;
const out: { r: number; c: number; sides: Side[] }[] = [];
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const sides: Side[] = [];
if (edge === "all") sides.push("위", "아래", "왼", "오른");
else {
if ((edge === "outer" || edge === "top") && r === r0) sides.push("위");
if ((edge === "outer" || edge === "bottom") && r === r1) sides.push("아래");
if ((edge === "outer" || edge === "left") && c === c0) sides.push("왼");
if ((edge === "outer" || edge === "right") && c === c1) sides.push("오른");
}
if (sides.length) out.push({ r, c, sides });
}
}
return out;
}
function applyBorderPreset(preset: BorderPreset): void {
const range = ctx.selection.범위[0];
if (!range) return;
if (preset === "none") {
ctx.dispatch({
종류: "서식",
시트: ctx.selection.시트,
범위: ctx.selection.범위,
바꿀: { 테두리: null },
});
return;
}
const style: BorderSide = { 선: lineSelect.value as BorderLine, 색: lineColor.value };
const cmds: Command[] = cellsFor(range, preset).map(({ r, c, sides }) => {
const cur = { ...(styleAt(ctx, r, c).테두리 ?? {}) };
for (const s of sides) cur[s] = style;
return {
종류: "서식",
시트: ctx.selection.시트,
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: { 테두리: cur },
};
});
if (cmds.length === 1) ctx.dispatch(cmds[0]);
else if (cmds.length > 1) ctx.dispatch({ 종류: "묶음", 명령: cmds });
}
// ── 숫자 형식 ─────────────────────────────────────────────────────────
const numfmtInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--numfmt",
attrs: {
list: "ss-toolbar-numfmt",
"aria-label": st("NumberFormat"),
title: st("NumberFormat_hint"),
},
}) as HTMLInputElement;
const numfmtList = el("datalist", {
attrs: { id: "ss-toolbar-numfmt" },
children: NUMFMT_PRESETS.map(([label, code]) =>
el("option", {
attrs: { value: code },
text: `${label} (${code || st("NumberFormat_general")})`,
}),
),
});
numfmtInput.append(numfmtList);
numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null }));
root.append(
group(undoBtn, redoBtn),
group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear),
group(hAlignSelect, vAlignSelect, wrapBtn),
group(mergeBtn),
group(borderBtn, borderPop),
group(numfmtInput),
);
function refresh(): void {
undoBtn.disabled = !ctx.history.canUndo();
redoBtn.disabled = !ctx.history.canRedo();
const { r, c } = ctx.selection.활성;
const style = styleAt(ctx, r, c);
fontInput.value = style.글꼴 ?? "";
sizeInput.value = style.크기 != null ? String(style.크기) : "";
boldBtn.classList.toggle("is-on", !!style.굵게);
italicBtn.classList.toggle("is-on", !!style.기울임);
underlineBtn.classList.toggle("is-on", !!style.밑줄);
strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "top";
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
mergeBtn.textContent = "";
mergeBtn.append(
el("span", {
className: "ui-btn__label",
text:
range && (ctx.sheet().병합 ?? []).includes(rangeToA1(range))
? st("Unmerge")
: st("Merge"),
}),
);
}
refresh();
return {
root,
refresh,
destroy() {
document.removeEventListener("click", closeBorderPop);
root.remove();
},
};
}
+277
View File
@@ -0,0 +1,277 @@
/* =============================================================================
* e_harness.ts — E(도구 모음 · 탭 · 메뉴 · 클립보드) 화면 시험 틀.
* A(주소 · 명령 · 참조 옮김) · B(숫자 형식) 는 아직 0 계약 몸만 있어 `fakes/`(진짜 대신 최소
* 흉내)를 `vite.harness.config.ts` 별칭으로 끼움. dispatch 는 이 파일이 직접 문서에 적용해
* 화면에 곧바로 비침 — 진짜 A `applyCommand` 가 아님(시험 전용).
* ========================================================================== */
import { mountToolbar } from "../../../A00_Common/spreadsheet/spreadsheet_toolbar";
import { mountTabs } from "../../../A00_Common/spreadsheet/spreadsheet_tabs";
import { attachMenu } from "../../../A00_Common/spreadsheet/spreadsheet_menu";
import {
attachClipboard,
parseClipboard,
} from "../../../A00_Common/spreadsheet/spreadsheet_clipboard";
import type {
Cell,
CellRange,
Command,
Sheet,
Workbook,
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
import type {
GridHandle,
Selection,
SpreadsheetContext,
} from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
import { colName, rangeToA1, toA1 } from "./fakes/fake_address";
const log: string[] = [];
const logEl = document.querySelector<HTMLPreElement>("#log")!;
function say(msg: string): void {
log.push(msg);
logEl.textContent = log.join("\n");
}
const book: Workbook = {
종류: "통합문서",
판: 1,
열: "test",
서식: [{}, { 굵게: true }],
시트: [
{
id: "s1",
이름: "측구수로400",
칸: {
A1: { 값: "터파기 계산서", 서식: 1 },
B1: { 값: 12.5 },
C1: { 식: "B1*2" },
},
병합: [],
},
],
활성: "s1",
};
function forEachCell(range: CellRange, fn: (r: number, c: number) => void): void {
for (let r = range.r0; r <= range.r1; r++) for (let c = range.c0; c <= range.c1; c++) fn(r, c);
}
function styleIndexAt(sheet: Sheet, r: number, c: number): number {
return (
sheet.칸[toA1(r, c)]?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
0
);
}
function internStyle(book: Workbook, style: Record<string, unknown>): number {
const json = JSON.stringify(style);
let idx = book.서식.findIndex((s) => JSON.stringify(s) === json);
if (idx === -1) {
book.서식.push(style as never);
idx = book.서식.length - 1;
}
return idx;
}
/** dispatch 가 흉내로 적용 — 진짜 A `applyCommand` 아님(시험 전용, 모양만 맞춤). */
function applyMock(cmd: Command): void {
switch (cmd.종류) {
case "묶음":
cmd.명령.forEach(applyMock);
return;
case "서식": {
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
cmd.범위.forEach((range) =>
forEachCell(range, (r, c) => {
const cur = { ...(book.서식[styleIndexAt(sheet, r, c)] ?? {}) };
for (const [k, v] of Object.entries(cmd.바꿀)) {
if (v === null) delete (cur as Record<string, unknown>)[k];
else (cur as Record<string, unknown>)[k] = v;
}
const key = toA1(r, c);
sheet.칸[key] = { ...sheet.칸[key], 서식: internStyle(book, cur) };
}),
);
return;
}
case "서식지움": {
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
cmd.범위.forEach((range) =>
forEachCell(range, (r, c) => {
const key = toA1(r, c);
if (sheet.칸[key]) delete sheet.칸[key].서식;
}),
);
return;
}
case "내용지움": {
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
cmd.범위.forEach((range) =>
forEachCell(range, (r, c) => {
const key = toA1(r, c);
const cell = sheet.칸[key];
if (!cell) return;
delete cell.값;
delete cell.식;
if (!Object.keys(cell).length) delete sheet.칸[key];
}),
);
return;
}
case "칸": {
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
for (const [key, val] of Object.entries(cmd.칸)) {
if (val === null) delete sheet.칸[key];
else sheet.칸[key] = val as Cell;
}
return;
}
case "병합": {
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
sheet.병합 = [...(sheet.병합 ?? []), rangeToA1(cmd.범위)];
return;
}
case "병합풀기": {
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
const a1 = rangeToA1(cmd.범위);
sheet.병합 = (sheet.병합 ?? []).filter((m) => m !== a1);
return;
}
case "시트더하기":
book.시트.splice(cmd.자리, 0, cmd.시트);
return;
case "시트지우기":
book.시트 = book.시트.filter((s) => s.id !== cmd.시트);
if (book.활성 === cmd.시트) book.활성 = book.시트[0]?.id;
return;
case "시트이름":
book.시트.find((s) => s.id === cmd.시트)!.이름 = cmd.이름;
return;
case "시트옮기기": {
const idx = book.시트.findIndex((s) => s.id === cmd.시트);
const [sheet] = book.시트.splice(idx, 1);
book.시트.splice(cmd.자리, 0, sheet);
return;
}
default:
return;
}
}
const selection: Selection = {
시트: "s1",
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
활성: { r: 0, c: 0 },
기준: { r: 0, c: 0 },
};
const root = document.querySelector<HTMLDivElement>("#ss-root")!;
const grid: GridHandle = {
root,
render() {},
invalidate() {},
renderSelection() {},
setRefHighlights() {},
cellBox: () => ({ x: 0, y: 0, w: 60, h: 20 }),
hitTest: (clientX) => {
const box = root.getBoundingClientRect();
const x = clientX - box.left;
if (x < 30) return { kind: "rowHead", r: 0, c: 0 };
if (x < 60) return { kind: "colHead", r: 0, c: 0 };
return { kind: "cell", r: selection.활성.r, c: selection.활성.c };
},
reveal() {},
visibleRange: () => ({ r0: 0, c0: 0, r1: 10, c1: 10 }),
editorSlot: () => ({ layer: root, box: { x: 0, y: 0, w: 60, h: 20 } }),
destroy() {},
};
const ctx: SpreadsheetContext = {
root,
book,
readOnly: false,
engine: {
update: () => [],
rebuild() {},
value: (sheetId, r, c) => {
const sheet = book.시트.find((s) => s.id === sheetId);
const cell = sheet?.칸[toA1(r, c)];
if (!cell) return null;
if (cell.식 !== undefined) return { n: 999n, d: 1n };
if (typeof cell.값 === "number") return { n: BigInt(Math.round(cell.값 * 100)), d: 100n };
if (cell.값 === undefined) return null;
return cell.값;
},
precedents: () => [],
snapshot: () => ({}),
},
history: {
push() {},
undo: () => null,
redo: () => null,
canUndo: () => false,
canRedo: () => false,
},
selection,
sheet: () => book.시트.find((s) => s.id === selection.시트)!,
dispatch(command) {
say(`dispatch: ${JSON.stringify(command)}`);
applyMock(command);
toolbar.refresh();
tabs.refresh();
},
undo() {
say("undo");
},
redo() {
say("redo");
},
select(sel) {
Object.assign(selection, sel);
say(`select: ${JSON.stringify(sel)}`);
toolbar.refresh();
},
showSheet(sheetId) {
book.활성 = sheetId;
selection.시트 = sheetId;
say(`showSheet: ${sheetId}`);
tabs.refresh();
toolbar.refresh();
},
grid,
editor: {
editing: () => false,
begin() {},
commit() {},
cancel() {},
sync() {},
},
};
const toolbar = mountToolbar(ctx);
const tabs = mountTabs(ctx);
attachMenu(ctx);
attachClipboard(ctx);
document.querySelector("#toolbar-slot")!.append(toolbar.root!);
document.querySelector("#tabs-slot")!.append(tabs.root!);
say("mounted — 도구 모음 · 탭 · 메뉴 · 클립보드 붙음");
(window as unknown as { __ctx: unknown; __book: unknown }).__ctx = ctx;
(window as unknown as { __ctx: unknown; __book: unknown }).__book = book;
// 클립보드 밖 → 안 시험 — 자리 표시자에 실제로 붙여넣기(Ctrl+V)를 받아 결과를 보임.
const pasteZone = document.querySelector<HTMLDivElement>("#paste-zone")!;
const pasteOut = document.querySelector<HTMLPreElement>("#paste-out")!;
pasteZone.addEventListener("paste", (ev) => {
ev.preventDefault();
const html = ev.clipboardData?.getData("text/html") ?? "";
const text = ev.clipboardData?.getData("text/plain") ?? "";
const block = parseClipboard(html, text, { r: 0, c: 0 });
pasteOut.textContent = `html:\n${html}\n\ntext:\n${text}\n\nblock:\n${JSON.stringify(block, null, 2)}`;
});
@@ -0,0 +1,78 @@
/* =============================================================================
* fake_address.ts — E 시험 전용 가짜(A 몫 `spreadsheet_address.ts` 대역).
* A 가 아직 없어(0 계약 직후) 도구 모음 · 탭 · 메뉴가 `toA1` 등을 부르면 던지므로
* 화면 시험에서만 `vite.harness.config.ts` 별칭으로 이것을 끼움. 진짜 코드는 안 건드림.
* ========================================================================== */
import type { CellAddress, CellRange } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
export function colName(c: number): string {
let n = c + 1;
let s = "";
while (n > 0) {
const rem = (n - 1) % 26;
s = String.fromCharCode(65 + rem) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
export function colIndex(letters: string): number {
let n = 0;
for (const ch of letters.toUpperCase()) {
const d = ch.charCodeAt(0) - 64;
if (d < 1 || d > 26) return -1;
n = n * 26 + d;
}
return n - 1;
}
export function toA1(r: number, c: number): string {
return `${colName(c)}${r + 1}`;
}
export function parseA1(text: string): CellAddress | null {
const m = /^\$?([A-Za-z]+)\$?(\d+)$/.exec(text.trim());
if (!m) return null;
const c = colIndex(m[1]);
const r = Number(m[2]) - 1;
if (c < 0 || r < 0) return null;
return { r, c };
}
export function rangeToA1(range: CellRange): string {
const a = toA1(range.r0, range.c0);
const b = toA1(range.r1, range.c1);
return a === b ? a : `${a}:${b}`;
}
export function parseRange(text: string): CellRange | null {
const t = text.trim();
if (t.includes(":")) {
const [a, b] = t.split(":");
const pa = parseA1(a);
const pb = parseA1(b);
if (!pa || !pb) return null;
return {
r0: Math.min(pa.r, pb.r),
c0: Math.min(pa.c, pb.c),
r1: Math.max(pa.r, pb.r),
c1: Math.max(pa.c, pb.c),
};
}
const p = parseA1(t);
return p ? { r0: p.r, c0: p.c, r1: p.r, c1: p.c } : null;
}
export function quoteSheet(name: string): string {
return /^[A-Za-z0-9_]+$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`;
}
const MAX_COLS = 16_384;
export function cellId(r: number, c: number): number {
return r * MAX_COLS + c;
}
export function fromCellId(id: number): CellAddress {
return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS };
}
@@ -0,0 +1,10 @@
/* =============================================================================
* fake_commands.ts — E 시험 전용 가짜(A 몫 `spreadsheet_commands.ts` 대역).
* 탭의 [+] 가 부르는 `emptySheet` 만 흉내(진짜 `applyCommand` 는 안 씀 · dispatch 는 harness 가 spy).
* ========================================================================== */
import type { Sheet } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
export function emptySheet(id: string, name: string): Sheet {
return { id, 이름: name, 칸: {} };
}
@@ -0,0 +1,17 @@
/* =============================================================================
* fake_numfmt.ts — E 시험 전용 가짜(B 몫 `spreadsheet_numfmt.ts` 대역).
* 클립보드 「안 → 밖」 시험이 보이는 글을 만들 수 있도록 아주 단순한 형식만 흉내.
* ========================================================================== */
import type { FormattedText, Scalar } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
export function formatValue(value: Scalar, code: string | undefined): FormattedText {
if (value === null) return { 글: "", 정렬: "left" };
if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" };
if (typeof value === "string") return { 글: value, 정렬: "left" };
if (typeof value === "object" && "error" in value) return { 글: value.error, 정렬: "center" };
const num = Number(value.n) / Number(value.d);
const text =
code === "0.00" ? num.toFixed(2) : code === "0" ? String(Math.round(num)) : String(num);
return { 글: text, 정렬: "right" };
}
@@ -0,0 +1,41 @@
/* =============================================================================
* fake_refshift.ts — E 시험 전용 가짜(A 몫 `spreadsheet_refshift.ts` 대역).
* 클립보드 붙여넣기 시험이 상대 참조 옮김 · 구글 R1C1 을 확인할 수 있도록 아주 단순히 흉내.
* ========================================================================== */
import { colIndex, colName } from "./fake_address";
import type { CellAddress } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
/** 복사 · 붙여넣기 — `$` 없는 A1 참조만 (dr, dc) 옮김. */
export function moveFormula(formula: string, dr: number, dc: number): string {
return formula.replace(/(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g, (_m, absCol, col, absRow, row) => {
let c = colIndex(col);
let r = Number(row) - 1;
if (!absCol) c += dc;
if (!absRow) r += dr;
if (c < 0 || r < 0) return "#REF!";
return `${absCol}${colName(c)}${absRow}${r + 1}`;
});
}
/** 구글 `R[0]C[-1]` 꼴 → 붙일 자리(`at`) 기준 A1 식. */
export function r1c1ToA1(formula: string, at: CellAddress): string {
return formula.replace(
/R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?/g,
(_m, rp: string | undefined, cp: string | undefined) => {
const row =
rp === undefined
? at.r + 1
: rp.startsWith("[")
? at.r + 1 + Number(rp.slice(1, -1))
: Number(rp);
const col =
cp === undefined
? at.c + 1
: cp.startsWith("[")
? at.c + 1 + Number(cp.slice(1, -1))
: Number(cp);
return `${colName(col - 1)}${row}`;
},
);
}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>E 화면 시험 — 도구 모음 · 탭 · 메뉴 · 클립보드</title>
<link rel="stylesheet" href="../../../ui_template/ui_template_theme.css" />
<style>
body {
margin: 16px;
}
#ss-root {
position: relative;
border: 1px solid #ccc;
min-height: 200px;
padding: 40px;
}
#tabs-slot {
margin-top: 8px;
}
#log,
#paste-out {
white-space: pre-wrap;
background: #f6f7fa;
border: 1px solid #ccc;
padding: 8px;
max-height: 200px;
overflow: auto;
font-size: 12px;
}
#paste-zone {
border: 2px dashed #999;
padding: 16px;
margin-top: 8px;
}
</style>
</head>
<body>
<h3>E 화면 시험 틀</h3>
<div id="toolbar-slot"></div>
<div id="ss-root">
<p>격자 자리 표시자 — 왼쪽 30px = 행 머리 · 다음 30px = 열 머리 · 나머지 = 칸(우클릭 시험)</p>
</div>
<div id="tabs-slot"></div>
<h4>붙여넣기 시험 — 여기 눌러 포커스 뒤 Ctrl+V</h4>
<div id="paste-zone" tabindex="0">여기 클릭 뒤 Ctrl+V</div>
<pre id="paste-out"></pre>
<h4>로그</h4>
<pre id="log"></pre>
<script type="module" src="./e_harness.ts"></script>
</body>
</html>
@@ -0,0 +1,63 @@
/* spreadsheet_clipboard.ts 의 TSV(밖 → 안, DOM 없이 되는 부분)만 — 값 · 참거짓 · 「엑셀 수식
표시」 식 잡기(끝수 · 「=」 한 글자는 글). HTML(DOMParser) 쪽은 ORCA 화면 시험으로.
진짜 앱 별칭(`@ui` 등)까지 그대로 vite 로 번들해 Node 로 돌린다(`build:formula` 방식) —
A(주소) · B(형식) 는 부르지 않는 길만 시험하므로 0 계약 몸(던짐)이어도 됨. */
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const out = mkdtempSync(join(tmpdir(), "aislo-ss-clip-"));
execFileSync(
process.execPath,
[
"./config/node_modules/vite/bin/vite.js",
"build",
"--configLoader",
"runner",
"--ssr",
"spreadsheet/spreadsheet_clipboard.ts",
"--outDir",
out,
],
{ stdio: "inherit" },
);
const { parseClipboard } = await import(pathToFileURL(join(out, "spreadsheet_clipboard.js")).href);
const at = { r: 0, c: 0 };
// 값 · 참거짓 잡기
{
const block = parseClipboard("", "10\t20\nTRUE\t3.5", at);
assert.equal(block.rows, 2);
assert.equal(block.cols, 2);
assert.deepEqual(block.칸["0,0"], { 값: 10 });
assert.deepEqual(block.칸["1,0"], { 값: true });
assert.deepEqual(block.칸["1,1"], { 값: 3.5 });
assert.equal(block.출처, "text");
assert.equal(block.원점, null);
}
// 「=」 뒤에 글이 있으면 식 · 한 글자 「=」 는 글(엑셀 「수식 표시」 대응)
{
const block = parseClipboard("", "=SUM(A1:A2)\t=", at);
assert.deepEqual(block.칸["0,0"], { 식: "SUM(A1:A2)" });
assert.deepEqual(block.칸["0,1"], { 값: "=" });
}
// 빈 칸은 안 적음(성긴 모양) · 끝 빈 줄 버림
{
const block = parseClipboard("", "a\t\tb\n\n", at);
assert.equal(Object.keys(block.칸).length, 2);
assert.equal(block.rows, 1);
assert.equal(block.cols, 3);
}
// 읽을 것 없으면 null
{
assert.equal(parseClipboard("", "", at), null);
}
console.log("OK — 클립보드 TSV 값 · 참거짓 · 「수식 표시」 식 잡기");
@@ -0,0 +1,35 @@
import { resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const repoRoot = resolve(__dirname, "../../..");
/* =============================================================================
* vite.harness.config.ts — E 화면 시험 전용 빌드(정본 `vite.config.ts` 는 안 건드림).
* A(`spreadsheet_address`) · A(`spreadsheet_commands`) 가 아직 0 계약 몸만 있어(던짐) 이
* 폴더의 가짜로 바꿔 끼움 — E 가 만든 화면 부품만 시험. 진짜 앱 빌드에는 안 쓰임.
* ========================================================================== */
export default {
root: __dirname,
publicDir: false,
resolve: {
alias: [
{ find: "./spreadsheet_address", replacement: resolve(__dirname, "./fakes/fake_address.ts") },
{
find: "./spreadsheet_commands",
replacement: resolve(__dirname, "./fakes/fake_commands.ts"),
},
{ find: "./spreadsheet_numfmt", replacement: resolve(__dirname, "./fakes/fake_numfmt.ts") },
{
find: "./spreadsheet_refshift",
replacement: resolve(__dirname, "./fakes/fake_refshift.ts"),
},
{ find: "@ui", replacement: resolve(repoRoot, "./ui_template") },
],
},
build: {
outDir: resolve(tmpdir(), "aislo_e_harness_dist"),
emptyOutDir: true,
},
};