From bfd71238297ea9a4471a51716b399cb7f91579ba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:33:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20E=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?=EB=AA=A8=EC=9D=8C=20=C2=B7=20=EC=8B=9C=ED=8A=B8=20=ED=83=AD=20?= =?UTF-8?q?=C2=B7=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=C2=B7=20=ED=81=B4=EB=A6=BD=EB=B3=B4=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기 - 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기 - 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용) - 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML) - E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가 --- .../spreadsheet/spreadsheet_clipboard.ts | 359 ++++++++++++++++- A00_Common/spreadsheet/spreadsheet_menu.ts | 206 +++++++++- A00_Common/spreadsheet/spreadsheet_tabs.ts | 162 +++++++- A00_Common/spreadsheet/spreadsheet_text.ts | 86 ++++ .../spreadsheet/spreadsheet_toolbar.css | 217 ++++++++++ A00_Common/spreadsheet/spreadsheet_toolbar.ts | 371 +++++++++++++++++- resources/tester/spreadsheet/e_harness.ts | 277 +++++++++++++ .../tester/spreadsheet/fakes/fake_address.ts | 78 ++++ .../tester/spreadsheet/fakes/fake_commands.ts | 10 + .../tester/spreadsheet/fakes/fake_numfmt.ts | 17 + .../tester/spreadsheet/fakes/fake_refshift.ts | 41 ++ resources/tester/spreadsheet/index.html | 52 +++ .../test_spreadsheet_clipboard_text.mjs | 63 +++ .../tester/spreadsheet/vite.harness.config.ts | 35 ++ 14 files changed, 1937 insertions(+), 37 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_text.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_toolbar.css create mode 100644 resources/tester/spreadsheet/e_harness.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_address.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_commands.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_numfmt.ts create mode 100644 resources/tester/spreadsheet/fakes/fake_refshift.ts create mode 100644 resources/tester/spreadsheet/index.html create mode 100644 resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs create mode 100644 resources/tester/spreadsheet/vite.harness.config.ts diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index add6efd6..19f3cd96 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -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 = { + 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, "&").replace(//g, ">"); +} + +/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */ +function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock { + const { r0, c0, r1, c1 } = range; + const 칸: Record = {}; + 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(); + 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( + `${escapeHtml(text)}`, + ); + } + rowsHtml.push(`${cellsHtml.join("")}`); + tsvRows.push(tsvCells.join("\t")); + } + const table = `${rowsHtml.join("")}
`; + const html = `${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; + merges: CellRange[]; +} + +function walkTable(table: HTMLTableElement): TableWalk { + const occupied = new Set(); + const owner = new Map(); + 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 = {}; + 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 = {}; + 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 = //.exec(html); + if (aislo) { + try { + return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock; + } catch { + // 손상됐으면 아래 일반 경로로 폴백. + } + } + if (/ = {}; + 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); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 6b99c139..e9ae8e67 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -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(); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_tabs.ts b/A00_Common/spreadsheet/spreadsheet_tabs.ts index a52e143d..c82fda65 100644 --- a/A00_Common/spreadsheet/spreadsheet_tabs.ts +++ b/A00_Common/spreadsheet/spreadsheet_tabs.ts @@ -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(`[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(); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts new file mode 100644 index 00000000..67546a24 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -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]; +} diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css new file mode 100644 index 00000000..864f26b1 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -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); +} diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 8bd4cef7..d388fcd3 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -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(); + }, + }; } diff --git a/resources/tester/spreadsheet/e_harness.ts b/resources/tester/spreadsheet/e_harness.ts new file mode 100644 index 00000000..e6cee49e --- /dev/null +++ b/resources/tester/spreadsheet/e_harness.ts @@ -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("#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): 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)[k]; + else (cur as Record)[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("#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("#paste-zone")!; +const pasteOut = document.querySelector("#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)}`; +}); diff --git a/resources/tester/spreadsheet/fakes/fake_address.ts b/resources/tester/spreadsheet/fakes/fake_address.ts new file mode 100644 index 00000000..b46dc19c --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_address.ts @@ -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 }; +} diff --git a/resources/tester/spreadsheet/fakes/fake_commands.ts b/resources/tester/spreadsheet/fakes/fake_commands.ts new file mode 100644 index 00000000..822b604c --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_commands.ts @@ -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, 칸: {} }; +} diff --git a/resources/tester/spreadsheet/fakes/fake_numfmt.ts b/resources/tester/spreadsheet/fakes/fake_numfmt.ts new file mode 100644 index 00000000..72bcb3f1 --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_numfmt.ts @@ -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" }; +} diff --git a/resources/tester/spreadsheet/fakes/fake_refshift.ts b/resources/tester/spreadsheet/fakes/fake_refshift.ts new file mode 100644 index 00000000..e6240b6f --- /dev/null +++ b/resources/tester/spreadsheet/fakes/fake_refshift.ts @@ -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}`; + }, + ); +} diff --git a/resources/tester/spreadsheet/index.html b/resources/tester/spreadsheet/index.html new file mode 100644 index 00000000..dde6ee7e --- /dev/null +++ b/resources/tester/spreadsheet/index.html @@ -0,0 +1,52 @@ + + + + + + E 화면 시험 — 도구 모음 · 탭 · 메뉴 · 클립보드 + + + + +

E 화면 시험 틀

+
+
+

격자 자리 표시자 — 왼쪽 30px = 행 머리 · 다음 30px = 열 머리 · 나머지 = 칸(우클릭 시험)

+
+
+

붙여넣기 시험 — 여기 눌러 포커스 뒤 Ctrl+V

+
여기 클릭 뒤 Ctrl+V
+

+    

로그

+

+    
+  
+
diff --git a/resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs b/resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs
new file mode 100644
index 00000000..cd1d385f
--- /dev/null
+++ b/resources/tester/spreadsheet/test_spreadsheet_clipboard_text.mjs
@@ -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 값 · 참거짓 · 「수식 표시」 식 잡기");
diff --git a/resources/tester/spreadsheet/vite.harness.config.ts b/resources/tester/spreadsheet/vite.harness.config.ts
new file mode 100644
index 00000000..8b47d9a0
--- /dev/null
+++ b/resources/tester/spreadsheet/vite.harness.config.ts
@@ -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,
+  },
+};