From d5c5090b79827f0762a8db864acd5c39c2976f68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:30:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(spreadsheet):=20C=20=EA=B2=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=E2=80=94=20DOM=204=20?= =?UTF-8?q?=ED=8C=90=20=C2=B7=20=EB=B3=91=ED=95=A9=20=C2=B7=20=ED=8B=80?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20=EC=88=A8=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=ED=8F=AD=20=EB=81=8C=EA=B8=B0=20=C2=B7=20=EC=84=9C=EC=8B=9D=20?= =?UTF-8?q?CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg --- .../spreadsheet/spreadsheet_cellstyle.ts | 175 +++++ A00_Common/spreadsheet/spreadsheet_grid.css | 202 +++++ A00_Common/spreadsheet/spreadsheet_grid.ts | 705 +++++++++++++++++- A00_Common/spreadsheet/spreadsheet_headers.ts | 223 ++++++ 4 files changed, 1297 insertions(+), 8 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_cellstyle.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_grid.css create mode 100644 A00_Common/spreadsheet/spreadsheet_headers.ts diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts new file mode 100644 index 00000000..28b2db92 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -0,0 +1,175 @@ +/* ============================================================================= + * spreadsheet_cellstyle.ts (주인 C) + * 서식 → CSS — 글꼴 · 정렬(균등 분할 · 선택 영역 가운데 · 세로 · 줄 바꿈) · 채움색 · + * 테두리(hair · thin · medium · thick · double · dotted · dashed · 색) · 겹친 테두리 고르기. + * 칸 하나가 위 · 왼 이웃과 테두리를 나눠 가짐(표 `border-collapse` 방식) — 겹쳐 그리지 않게 + * 위 · 왼만 두 이웃 중 굵은 쪽으로 그리고 오른 · 아래는 시트 끝 칸만 그림(`resolveEdgeBorders`). + * 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`. + * ========================================================================== */ + +import { colName, toA1 } from "./spreadsheet_address"; +import type { + BorderLine, + BorderSide, + CellStyle, + HAlign, + Sheet, + VAlign, + Workbook, +} from "./spreadsheet_types"; + +/** 선 굵기 차례 — 겹치면 굵은 쪽이 이김(엑셀과 완전히 같지 않음 · 근사). */ +const BORDER_WEIGHT: Record = { + hair: 0, + dotted: 1, + dashed: 2, + thin: 3, + double: 4, + medium: 5, + thick: 6, +}; + +/** 두 이웃 칸이 한 자리에 그리려는 테두리 중 굵은 쪽. 없으면 있는 쪽 · 둘 다 없으면 undefined. */ +export function strongerBorder(a?: BorderSide, b?: BorderSide): BorderSide | undefined { + if (!a) return b; + if (!b) return a; + return BORDER_WEIGHT[b.선] > BORDER_WEIGHT[a.선] ? b : a; +} + +/** 칸 하나가 실제로 그릴 네 변 — 위 · 왼은 이웃과 겹쳐 고름 · 오른 · 아래는 시트 끝(그 다음 칸이 없음)일 때만. */ +export interface ResolvedBorders { + 위?: BorderSide; + 아래?: BorderSide; + 왼?: BorderSide; + 오른?: BorderSide; +} + +/** `서식` 표 번호 — 칸 → 행 → 열 → 0(기본) 차례(칸 안의 계약 주석과 같음). */ +export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number { + const cell = sheet.칸[key]; + if (cell?.서식 !== undefined) return cell.서식; + const rowFmt = sheet.행?.[String(r + 1)]?.서식; + if (rowFmt !== undefined) return rowFmt; + const colFmt = sheet.열?.[colName(c)]?.서식; + if (colFmt !== undefined) return colFmt; + return 0; +} + +/** (r, c) 칸이 쓸 서식 — 서식 표 밖 번호는 기본(빈 `{}`)으로. */ +export function cellStyleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle { + const key = toA1(r, c); + const idx = styleIndexAt(sheet, r, c, key); + return book.서식[idx] ?? {}; +} + +/** 칸 하나 · 병합 칸 한 덩이(box)의 테두리 — 네 변마다 안쪽 이웃과 겹쳐 굵은 쪽으로 고름. + * 병합이면 왼위(r0,c0) · 오른아래(r1,c1) 이 다른 칸이라 네 변을 따로 봄(테두리는 칸마다 있음). */ +export function resolveBoxBorders( + book: Workbook, + sheet: Sheet, + r0: number, + c0: number, + r1: number, + c1: number, + lastRow: number, + lastCol: number, +): ResolvedBorders { + const top = cellStyleAt(book, sheet, r0, c0).테두리?.위; + const aboveOf = r0 > 0 ? cellStyleAt(book, sheet, r0 - 1, c0).테두리?.아래 : undefined; + const left = cellStyleAt(book, sheet, r0, c0).테두리?.왼; + const leftOf = c0 > 0 ? cellStyleAt(book, sheet, r0, c0 - 1).테두리?.오른 : undefined; + const bottomOwn = cellStyleAt(book, sheet, r1, c0).테두리?.아래; + const belowOf = r1 < lastRow ? cellStyleAt(book, sheet, r1 + 1, c0).테두리?.위 : undefined; + const rightOwn = cellStyleAt(book, sheet, r0, c1).테두리?.오른; + const rightOf = c1 < lastCol ? cellStyleAt(book, sheet, r0, c1 + 1).테두리?.왼 : undefined; + return { + 위: strongerBorder(top, aboveOf), + 왼: strongerBorder(left, leftOf), + 아래: r1 >= lastRow ? bottomOwn : strongerBorder(bottomOwn, belowOf), + 오른: c1 >= lastCol ? rightOwn : strongerBorder(rightOwn, rightOf), + }; +} + +/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */ +function borderCss(side?: BorderSide): string { + if (!side) return ""; + const color = side.색 ?? "#000000"; + switch (side.선) { + case "hair": + return `1px dotted ${color}`; + case "thin": + return `1px solid ${color}`; + case "medium": + return `2px solid ${color}`; + case "thick": + return `3px solid ${color}`; + case "double": + return `3px double ${color}`; + case "dotted": + return `1px dotted ${color}`; + case "dashed": + return `1px dashed ${color}`; + } +} + +/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */ +const HALIGN_CSS: Record = { + general: "left", + left: "left", + right: "right", + center: "center", + distributed: "justify", + centerContinuous: "center", +}; + +const VALIGN_CSS: Record = { + top: "flex-start", + center: "center", + bottom: "flex-end", +}; + +/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */ +export function applyCellStyle( + cell: HTMLElement, + style: CellStyle, + borders: ResolvedBorders, + hAlign: HAlign, +): void { + const s = cell.style; + s.fontFamily = style.글꼴 ?? ""; + s.fontSize = style.크기 ? `${style.크기}pt` : ""; + s.fontWeight = style.굵게 ? "700" : "400"; + s.fontStyle = style.기울임 ? "italic" : "normal"; + s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"] + .filter(Boolean) + .join(" "); + s.color = style.글자색 ?? ""; + s.backgroundColor = style.채움 ?? ""; + s.borderTop = borderCss(borders.위); + s.borderLeft = borderCss(borders.왼); + s.borderBottom = borderCss(borders.아래); + s.borderRight = borderCss(borders.오른); + s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; + s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; + + let text = cell.firstElementChild as HTMLElement | null; + if (!text || !text.classList.contains("aislo-cell__text")) { + text = document.createElement("div"); + text.className = "aislo-cell__text"; + cell.replaceChildren(text); + } + text.style.textAlign = HALIGN_CSS[hAlign]; + text.style.textAlignLast = hAlign === "distributed" ? "justify" : ""; +} + +/** 칸 글 — 서식 CSS 와 따로 두어 `invalidate()` 가 값만 바꿀 때 서식을 다시 잴 필요가 없게. */ +export function setCellText(cell: HTMLElement, text: string, title?: string): void { + const node = cell.firstElementChild as HTMLElement | null; + if (node && node.classList.contains("aislo-cell__text")) { + node.textContent = text; + } else { + cell.textContent = text; + } + if (title) cell.title = title; + else cell.removeAttribute("title"); +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css new file mode 100644 index 00000000..6ec0e9dd --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -0,0 +1,202 @@ +/* ============================================================================= + * spreadsheet_grid.css (주인 C) + * 격자 4 판 · 머리 · 칸 기본 모양. 색 · 간격은 여기 변수만(다른 파일은 이 클래스만 씀). + * ========================================================================== */ + +.aislo-grid { + --aislo-grid-line: #d9d9d9; + --aislo-grid-head-bg: #f3f3f3; + --aislo-grid-head-fg: #444; + --aislo-grid-head-border: #cfcfcf; + --aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12); + --aislo-grid-selected: #1a73e8; + + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + font: + 10pt "굴림", + sans-serif; + background: #fff; + user-select: none; + -webkit-user-select: none; +} + +.aislo-grid__corner, +.aislo-grid__colhead-frozen, +.aislo-grid__colhead-scroll, +.aislo-grid__rowhead-frozen, +.aislo-grid__rowhead-scroll, +.aislo-grid__pane { + position: absolute; + overflow: hidden; +} + +.aislo-grid__pane--br { + overflow: auto; +} + +.aislo-grid__corner { + z-index: 5; + background: var(--aislo-grid-head-bg); + border-right: 1px solid var(--aislo-grid-head-border); + border-bottom: 1px solid var(--aislo-grid-head-border); +} + +.aislo-grid__colhead-frozen, +.aislo-grid__colhead-scroll { + z-index: 4; + background: var(--aislo-grid-head-bg); + border-bottom: 1px solid var(--aislo-grid-head-border); +} + +.aislo-grid__rowhead-frozen, +.aislo-grid__rowhead-scroll { + z-index: 4; + background: var(--aislo-grid-head-bg); + border-right: 1px solid var(--aislo-grid-head-border); +} + +.aislo-grid__colhead-frozen, +.aislo-grid__rowhead-frozen { + z-index: 6; + box-shadow: 2px 2px 4px var(--aislo-grid-freeze-shadow); +} + +.aislo-grid__colhead-inner, +.aislo-grid__rowhead-inner { + position: absolute; + top: 0; + left: 0; + will-change: transform; +} + +.aislo-grid__colhead, +.aislo-grid__rowhead { + position: absolute; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.82em; + font-weight: 600; + color: var(--aislo-grid-head-fg); + border-right: 1px solid var(--aislo-grid-head-border); + border-bottom: 1px solid var(--aislo-grid-head-border); + top: 0; +} + +.aislo-grid__rowhead { + left: 0; + top: auto; +} + +.aislo-grid__colhead--hidden-before, +.aislo-grid__rowhead--hidden-before { + border-left: 3px double #888; +} + +.aislo-grid__head-label { + pointer-events: none; + overflow: hidden; + text-overflow: ellipsis; +} + +.aislo-grid__resize { + position: absolute; + z-index: 2; +} + +.aislo-grid__resize--col { + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; +} + +.aislo-grid__resize--row { + left: 0; + bottom: -3px; + width: 100%; + height: 6px; + cursor: row-resize; +} + +.aislo-grid__pane-inner { + position: absolute; + top: 0; + left: 0; + will-change: transform; +} + +.aislo-grid__pane--br .aislo-grid__spacer { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + pointer-events: none; +} + +.aislo-cell { + position: absolute; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0 2px; + background: #fff; + cursor: cell; +} + +.aislo-grid--gridlines .aislo-cell { + border-right: 1px solid var(--aislo-grid-line); + border-bottom: 1px solid var(--aislo-grid-line); +} + +.aislo-cell__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: inherit; +} + +.aislo-cell.is-error { + color: #c00; +} + +.aislo-cell.is-selected { + outline: 2px solid var(--aislo-grid-selected); + outline-offset: -1px; + z-index: 1; +} + +.aislo-grid__ref-highlight { + position: absolute; + box-sizing: border-box; + pointer-events: none; + border: 1.5px solid; + z-index: 3; +} + +.aislo-grid__fill-handle { + position: absolute; + width: 6px; + height: 6px; + background: var(--aislo-grid-selected); + border: 1px solid #fff; + cursor: crosshair; + z-index: 3; +} + +.aislo-grid__overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 7; +} + +.aislo-grid__overlay > * { + pointer-events: auto; +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 30f251ac..de32e299 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -3,16 +3,705 @@ * 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 · * 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이. * 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`. - * 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`. 0 계약 머리 — 몸은 C 가 채움. + * 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`. + * + * 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) · + * 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만). + * 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`). * ========================================================================== */ -import type { GridHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_grid.css"; +import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; +import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { + applyCellStyle, + cellStyleAt, + resolveBoxBorders, + setCellText, + type ResolvedBorders, +} from "./spreadsheet_cellstyle"; +import { + buildColGeometry, + buildRowGeometry, + commitColWidth, + commitRowHeight, + makeColHeaderCell, + makeRowHeaderCell, + startDragResize, + visibleIndices, + type LineGeometry, +} from "./spreadsheet_headers"; +import { formatValue } from "./spreadsheet_numfmt"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { + CellRange, + ErrorValue, + HAlign, + Scalar, + Sheet, + SheetCellAddress, +} from "./spreadsheet_types"; +import type { + CellBox, + GridHandle, + HitResult, + RefHighlight, + SpreadsheetContext, +} from "./spreadsheet_view_types"; -const todo = (): never => { - throw new Error("spreadsheet_grid: 아직 없음(C)"); -}; +const HEAD_ROW_H = 24; +const HEAD_COL_W = 46; +const RESIZE_HOTZONE = 4; +const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"]; -/** `ctx.grid` 는 D 가 이 결과로 채움 — 만들 때 `ctx.grid` · `ctx.editor` 를 부르지 말 것 */ -export function createGrid(_ctx: SpreadsheetContext): GridHandle { - return todo(); +/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */ +interface CellPool { + used: Map; + free: HTMLElement[]; +} +const createPool = (): CellPool => ({ used: new Map(), free: [] }); + +function poolGet( + pool: CellPool, + key: string, + container: HTMLElement, + className: string, +): HTMLElement { + const found = pool.used.get(key); + if (found) return found; + const node = pool.free.pop() ?? document.createElement("div"); + node.className = className; + pool.used.set(key, node); + container.append(node); + return node; +} + +function poolSweep(pool: CellPool, keep: Set): void { + for (const [key, node] of pool.used) { + if (keep.has(key)) continue; + pool.used.delete(key); + node.remove(); + pool.free.push(node); + } +} + +function toScalar(value: number | string | boolean): Scalar { + return typeof value === "number" ? toFrac(value) : value; +} + +/** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */ +function computeExtent(sheet: Sheet): { rows: number; cols: number } { + let maxR = 0; + let maxC = 0; + for (const key of Object.keys(sheet.칸)) { + const addr = parseA1(key); + if (!addr) continue; + maxR = Math.max(maxR, addr.r); + maxC = Math.max(maxC, addr.c); + } + for (const key of Object.keys(sheet.행 ?? {})) maxR = Math.max(maxR, Number(key) - 1); + for (const key of Object.keys(sheet.열 ?? {})) maxC = Math.max(maxC, colIndex(key)); + for (const m of sheet.병합 ?? []) { + const range = parseRange(m); + if (!range) continue; + maxR = Math.max(maxR, range.r1); + maxC = Math.max(maxC, range.c1); + } + return { + rows: Math.min(MAX_ROWS, Math.max(maxR + 100, 200)), + cols: Math.min(MAX_COLS, Math.max(maxC + 20, 60)), + }; +} + +function buildMergeMap(sheet: Sheet): Map { + const map = new Map(); + for (const m of sheet.병합 ?? []) { + const range = parseRange(m); + if (!range) continue; + for (let r = range.r0; r <= range.r1; r += 1) { + for (let c = range.c0; c <= range.c1; c += 1) map.set(`${r},${c}`, range); + } + } + return map; +} + +interface CellDisplay { + text: string; + hAlign: HAlign; + title: string; + isError: boolean; +} + +function cellDisplay( + ctx: SpreadsheetContext, + sheet: Sheet, + r: number, + c: number, + hAlign0: HAlign, + code?: string, +): CellDisplay { + const cell = sheet.칸[toA1(r, c)]; + const scalar: Scalar = + cell?.식 !== undefined + ? ctx.engine.value(sheet.id, r, c) + : cell?.값 !== undefined + ? toScalar(cell.값) + : null; + const formatted = formatValue(scalar, code); + const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; + const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬; + const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; + return { text: formatted.글, hAlign, title, isError }; +} + +export function createGrid(ctx: SpreadsheetContext): GridHandle { + const root = document.createElement("div"); + root.className = "aislo-grid"; + + const corner = document.createElement("div"); + corner.className = "aislo-grid__corner"; + const colHeadFrozen = paneDiv("aislo-grid__colhead-frozen"); + const colHeadScroll = paneDiv("aislo-grid__colhead-scroll"); + const rowHeadFrozen = paneDiv("aislo-grid__rowhead-frozen"); + const rowHeadScroll = paneDiv("aislo-grid__rowhead-scroll"); + const paneTL = paneDiv("aislo-grid__pane aislo-grid__pane--tl"); + const paneTR = paneDiv("aislo-grid__pane aislo-grid__pane--tr"); + const paneBL = paneDiv("aislo-grid__pane aislo-grid__pane--bl"); + const paneBR = paneDiv("aislo-grid__pane aislo-grid__pane--br"); + const overlay = paneDiv("aislo-grid__overlay"); + + const colHeadInnerFrozen = innerDiv("aislo-grid__colhead-inner"); + const colHeadInnerScroll = innerDiv("aislo-grid__colhead-inner"); + const rowHeadInnerFrozen = innerDiv("aislo-grid__rowhead-inner"); + const rowHeadInnerScroll = innerDiv("aislo-grid__rowhead-inner"); + const paneTLInner = innerDiv("aislo-grid__pane-inner"); + const paneTRInner = innerDiv("aislo-grid__pane-inner"); + const paneBLInner = innerDiv("aislo-grid__pane-inner"); + const paneBRInner = innerDiv("aislo-grid__pane-inner"); + const spacer = document.createElement("div"); + spacer.className = "aislo-grid__spacer"; + + colHeadFrozen.append(colHeadInnerFrozen); + colHeadScroll.append(colHeadInnerScroll); + rowHeadFrozen.append(rowHeadInnerFrozen); + rowHeadScroll.append(rowHeadInnerScroll); + paneTL.append(paneTLInner); + paneTR.append(paneTRInner); + paneBL.append(paneBLInner); + paneBR.append(paneBRInner, spacer); + root.append( + corner, + colHeadFrozen, + colHeadScroll, + rowHeadFrozen, + rowHeadScroll, + paneTL, + paneTR, + paneBL, + paneBR, + overlay, + ); + + const pools = { tl: createPool(), tr: createPool(), bl: createPool(), br: createPool() }; + const colHeadPools = { frozen: createPool(), scroll: createPool() }; + const rowHeadPools = { frozen: createPool(), scroll: createPool() }; + + let colGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet()); + let rowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet()); + let extent = computeExtent(ctx.sheet()); + let merges = buildMergeMap(ctx.sheet()); + let frozenRows = 0; + let frozenCols = 0; + let frozenW = 0; + let frozenH = 0; + + const resize = new ResizeObserver(() => layoutPanes()); + resize.observe(root); + + /** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */ + function layoutPanes(): void { + const w = root.clientWidth; + const h = root.clientHeight; + frozenW = colGeo.total(frozenCols); + frozenH = rowGeo.total(frozenRows); + corner.style.width = `${HEAD_COL_W}px`; + corner.style.height = `${HEAD_ROW_H}px`; + colHeadFrozen.style.left = `${HEAD_COL_W}px`; + colHeadFrozen.style.top = "0"; + colHeadFrozen.style.width = `${frozenW}px`; + colHeadFrozen.style.height = `${HEAD_ROW_H}px`; + colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`; + colHeadScroll.style.top = "0"; + colHeadScroll.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; + colHeadScroll.style.height = `${HEAD_ROW_H}px`; + rowHeadFrozen.style.left = "0"; + rowHeadFrozen.style.top = `${HEAD_ROW_H}px`; + rowHeadFrozen.style.width = `${HEAD_COL_W}px`; + rowHeadFrozen.style.height = `${frozenH}px`; + rowHeadScroll.style.left = "0"; + rowHeadScroll.style.top = `${HEAD_ROW_H + frozenH}px`; + rowHeadScroll.style.width = `${HEAD_COL_W}px`; + rowHeadScroll.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; + paneTL.style.left = `${HEAD_COL_W}px`; + paneTL.style.top = `${HEAD_ROW_H}px`; + paneTL.style.width = `${frozenW}px`; + paneTL.style.height = `${frozenH}px`; + paneTR.style.left = `${HEAD_COL_W + frozenW}px`; + paneTR.style.top = `${HEAD_ROW_H}px`; + paneTR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; + paneTR.style.height = `${frozenH}px`; + paneBL.style.left = `${HEAD_COL_W}px`; + paneBL.style.top = `${HEAD_ROW_H + frozenH}px`; + paneBL.style.width = `${frozenW}px`; + paneBL.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; + paneBR.style.left = `${HEAD_COL_W + frozenW}px`; + paneBR.style.top = `${HEAD_ROW_H + frozenH}px`; + paneBR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; + paneBR.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; + spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`; + overlay.style.left = `${HEAD_COL_W}px`; + overlay.style.top = `${HEAD_ROW_H}px`; + overlay.style.right = "0"; + overlay.style.bottom = "0"; + renderVisible(); + } + + /** `br` 스크롤 위치 → 세 판(머리 둘 · tr · bl)에 옮겨 그리고 보이는 칸을 다시. */ + function onScroll(): void { + const left = paneBR.scrollLeft; + const top = paneBR.scrollTop; + colHeadInnerScroll.style.transform = `translateX(${-left}px)`; + paneTRInner.style.transform = `translateX(${-left}px)`; + rowHeadInnerScroll.style.transform = `translateY(${-top}px)`; + paneBLInner.style.transform = `translateY(${-top}px)`; + renderVisible(); + } + paneBR.addEventListener("scroll", onScroll); + + const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false; + + function renderRowOfCells( + rowsVisible: number[], + colsVisible: number[], + pool: CellPool, + container: HTMLElement, + baseX: number, + baseY: number, + ): void { + const sheet = ctx.sheet(); + const book = ctx.book; + const keep = new Set(); + for (const r of rowsVisible) { + for (const c of colsVisible) { + const merge = merges.get(`${r},${c}`); + if (merge && (merge.r0 !== r || merge.c0 !== c)) continue; + const r0 = merge ? merge.r0 : r; + const c0 = merge ? merge.c0 : c; + const r1 = merge ? merge.r1 : r; + const c1 = merge ? merge.c1 : c; + const key = `${r0},${c0}`; + if (keep.has(key)) continue; + keep.add(key); + const cellEl = poolGet(pool, key, container, "aislo-cell"); + const x = colGeo.offset(c0) - baseX; + const y = rowGeo.offset(r0) - baseY; + const w = colGeo.offset(c1 + 1) - colGeo.offset(c0); + const h = rowGeo.offset(r1 + 1) - rowGeo.offset(r0); + cellEl.style.left = `${x}px`; + cellEl.style.top = `${y}px`; + cellEl.style.width = `${w}px`; + cellEl.style.height = `${h}px`; + cellEl.dataset.r = String(r0); + cellEl.dataset.c = String(c0); + const style = cellStyleAt(book, sheet, r0, c0); + const borders: ResolvedBorders = resolveBoxBorders( + book, + sheet, + r0, + c0, + r1, + c1, + extent.rows - 1, + extent.cols - 1, + ); + const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식); + applyCellStyle(cellEl, style, borders, display.hAlign); + setCellText(cellEl, display.text, display.title || undefined); + cellEl.classList.toggle("is-error", display.isError); + cellEl.classList.toggle( + "is-selected", + ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0, + ); + } + } + poolSweep(pool, keep); + } + + function layoutColHeaders( + cols: number[], + pool: CellPool, + container: HTMLElement, + base: number, + ): void { + const keep = new Set(); + cols.forEach((c, i) => { + const key = String(c); + keep.add(key); + const prev = i > 0 ? cols[i - 1] : c - 1; + const hiddenBefore = c - prev > 1; + let cellEl = pool.used.get(key); + if (!cellEl) { + const built = makeColHeaderCell(c, 0, 0, hiddenBefore); + cellEl = built.el; + pool.used.set(key, cellEl); + container.append(cellEl); + const handle = cellEl.querySelector(".aislo-grid__resize--col")!; + startDragResize({ + handle, + axis: "col", + currentPx: () => colGeo.size(c), + onPreview: (px) => { + cellEl!.style.width = `${px}px`; + }, + onCommit: (px) => commitColWidth(ctx, c, px), + }); + } + cellEl.style.left = `${colGeo.offset(c) - base}px`; + cellEl.style.width = `${colGeo.size(c)}px`; + cellEl.classList.toggle("aislo-grid__colhead--hidden-before", hiddenBefore); + }); + poolSweep(pool, keep); + } + + function layoutRowHeaders( + rows: number[], + pool: CellPool, + container: HTMLElement, + base: number, + ): void { + const keep = new Set(); + rows.forEach((r, i) => { + const key = String(r); + keep.add(key); + const prev = i > 0 ? rows[i - 1] : r - 1; + const hiddenBefore = r - prev > 1; + let cellEl = pool.used.get(key); + if (!cellEl) { + const built = makeRowHeaderCell(r, 0, 0, hiddenBefore); + cellEl = built.el; + pool.used.set(key, cellEl); + container.append(cellEl); + const handle = cellEl.querySelector(".aislo-grid__resize--row")!; + startDragResize({ + handle, + axis: "row", + currentPx: () => rowGeo.size(r), + onPreview: (px) => { + cellEl!.style.height = `${px}px`; + }, + onCommit: (px) => commitRowHeight(ctx, r, px), + }); + } + cellEl.style.top = `${rowGeo.offset(r) - base}px`; + cellEl.style.height = `${rowGeo.size(r)}px`; + cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore); + }); + poolSweep(pool, keep); + } + + function renderVisible(): void { + root.classList.toggle("aislo-grid--gridlines", gridlines()); + const w = root.clientWidth; + const h = root.clientHeight; + const scrollW = Math.max(0, w - HEAD_COL_W - frozenW); + const scrollH = Math.max(0, h - HEAD_ROW_H - frozenH); + const frozenColsVisible = visibleIndices(colGeo, 0, Math.max(0, frozenCols - 1)); + const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(paneBR.scrollLeft)); + const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW) + 1); + const scrollColsVisible = visibleIndices( + colGeo, + scrollColFrom, + Math.max(scrollColFrom, scrollColTo), + ); + const frozenRowsVisible = visibleIndices(rowGeo, 0, Math.max(0, frozenRows - 1)); + const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(paneBR.scrollTop)); + const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(paneBR.scrollTop + scrollH) + 1); + const scrollRowsVisible = visibleIndices( + rowGeo, + scrollRowFrom, + Math.max(scrollRowFrom, scrollRowTo), + ); + + layoutColHeaders(frozenColsVisible, colHeadPools.frozen, colHeadInnerFrozen, 0); + layoutColHeaders( + scrollColsVisible, + colHeadPools.scroll, + colHeadInnerScroll, + colGeo.offset(frozenCols), + ); + layoutRowHeaders(frozenRowsVisible, rowHeadPools.frozen, rowHeadInnerFrozen, 0); + layoutRowHeaders( + scrollRowsVisible, + rowHeadPools.scroll, + rowHeadInnerScroll, + rowGeo.offset(frozenRows), + ); + + renderRowOfCells(frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0); + renderRowOfCells( + frozenRowsVisible, + scrollColsVisible, + pools.tr, + paneTRInner, + colGeo.offset(frozenCols), + 0, + ); + renderRowOfCells( + scrollRowsVisible, + frozenColsVisible, + pools.bl, + paneBLInner, + 0, + rowGeo.offset(frozenRows), + ); + renderRowOfCells( + scrollRowsVisible, + scrollColsVisible, + pools.br, + paneBRInner, + colGeo.offset(frozenCols), + rowGeo.offset(frozenRows), + ); + + renderSelection(); + } + + function render(): void { + const sheet = ctx.sheet(); + colGeo = buildColGeometry(ctx.book, sheet); + rowGeo = buildRowGeometry(ctx.book, sheet); + extent = computeExtent(sheet); + merges = buildMergeMap(sheet); + frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows); + frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols); + spacer.style.width = "1px"; + spacer.style.height = "1px"; + paneBR.scrollLeft = 0; + paneBR.scrollTop = 0; + layoutPanes(); + } + + /** 값 · 서식이 바뀐 칸만 — 지금 그려진 칸이면 다시(구조는 안 바꿈 · `render()` 는 구조 바뀔 때만). */ + function invalidate(cells: SheetCellAddress[]): void { + const sheet = ctx.sheet(); + const dirty = new Set(cells.filter((c) => c.시트 === sheet.id).map((c) => `${c.r},${c.c}`)); + if (dirty.size === 0) return; + const anyDirty = (pool: CellPool): boolean => { + for (const key of pool.used.keys()) if (dirty.has(key)) return true; + return false; + }; + if (anyDirty(pools.tl) || anyDirty(pools.tr) || anyDirty(pools.bl) || anyDirty(pools.br)) + renderVisible(); + } + + function boxOf(r: number, c: number): CellBox { + const merge = merges.get(`${r},${c}`); + const r0 = merge ? merge.r0 : r; + const c0 = merge ? merge.c0 : c; + const r1 = merge ? merge.r1 : r; + const c1 = merge ? merge.c1 : c; + return { + x: colGeo.offset(c0), + y: rowGeo.offset(r0), + w: colGeo.offset(c1 + 1) - colGeo.offset(c0), + h: rowGeo.offset(r1 + 1) - rowGeo.offset(r0), + }; + } + + function screenBoxOf(r: number, c: number): CellBox { + const box = boxOf(r, c); + const inFrozenCol = c < frozenCols; + const inFrozenRow = r < frozenRows; + const x = HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft); + const y = HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop); + return { x, y, w: box.w, h: box.h }; + } + + function renderSelection(): void { + let selLayer = overlay.querySelector(".aislo-grid__sel-layer"); + if (!selLayer) { + selLayer = document.createElement("div"); + selLayer.className = "aislo-grid__sel-layer"; + overlay.append(selLayer); + } + selLayer.replaceChildren(); + const sel = ctx.selection; + if (!sel || sel.시트 !== ctx.sheet().id) return; + for (const [i, range] of sel.범위.entries()) { + const box: CellBox = { + x: colGeo.offset(range.c0), + y: rowGeo.offset(range.r0), + w: colGeo.offset(range.c1 + 1) - colGeo.offset(range.c0), + h: rowGeo.offset(range.r1 + 1) - rowGeo.offset(range.r0), + }; + const inFrozenCol = range.c0 < frozenCols; + const inFrozenRow = range.r0 < frozenRows; + const screen: CellBox = { + x: HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft), + y: HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop), + w: box.w, + h: box.h, + }; + const node = document.createElement("div"); + node.className = "aislo-grid__ref-highlight"; + node.style.left = `${screen.x}px`; + node.style.top = `${screen.y}px`; + node.style.width = `${screen.w}px`; + node.style.height = `${screen.h}px`; + node.style.borderColor = "#1a73e8"; + node.style.borderWidth = i === 0 ? "2px" : "1px"; + selLayer!.append(node); + } + const active = boxOf(sel.활성.r, sel.활성.c); + const inFrozenCol = sel.활성.c < frozenCols; + const inFrozenRow = sel.활성.r < frozenRows; + const handle = document.createElement("div"); + handle.className = "aislo-grid__fill-handle"; + handle.style.left = `${HEAD_COL_W + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`; + handle.style.top = `${HEAD_ROW_H + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`; + selLayer!.append(handle); + } + + function setRefHighlights(list: RefHighlight[]): void { + let refLayer = overlay.querySelector(".aislo-grid__ref-layer"); + if (!refLayer) { + refLayer = document.createElement("div"); + refLayer.className = "aislo-grid__ref-layer"; + overlay.append(refLayer); + } + refLayer.replaceChildren(); + for (const ref of list) { + if (ref.시트 !== ctx.sheet().id) continue; + const box = boxOf(ref.범위.r0, ref.범위.c0); + const box2 = boxOf(ref.범위.r1, ref.범위.c1); + const merged: CellBox = { + x: box.x, + y: box.y, + w: box2.x + box2.w - box.x, + h: box2.y + box2.h - box.y, + }; + const inFrozenCol = ref.범위.c0 < frozenCols; + const inFrozenRow = ref.범위.r0 < frozenRows; + const node = document.createElement("div"); + node.className = "aislo-grid__ref-highlight"; + node.style.left = `${HEAD_COL_W + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`; + node.style.top = `${HEAD_ROW_H + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`; + node.style.width = `${merged.w}px`; + node.style.height = `${merged.h}px`; + node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length]; + refLayer!.append(node); + } + } + + function hitTest(clientX: number, clientY: number): HitResult | null { + const bounds = root.getBoundingClientRect(); + const x = clientX - bounds.left; + const y = clientY - bounds.top; + if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null; + if (x < HEAD_COL_W && y < HEAD_ROW_H) return { kind: "corner", r: 0, c: 0 }; + const contentX = + x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > frozenW ? paneBR.scrollLeft : 0); + const contentY = + y < HEAD_ROW_H ? -1 : y - HEAD_ROW_H + (y - HEAD_ROW_H > frozenH ? paneBR.scrollTop : 0); + if (y < HEAD_ROW_H) { + const c = colGeo.indexAt(contentX); + const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE; + return { kind: edge ? "colEdge" : "colHead", r: 0, c }; + } + if (x < HEAD_COL_W) { + const r = rowGeo.indexAt(contentY); + const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE; + return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 }; + } + const c = colGeo.indexAt(contentX); + const r = rowGeo.indexAt(contentY); + const sel = ctx.selection; + if (sel && sel.시트 === ctx.sheet().id) { + const active = boxOf(sel.활성.r, sel.활성.c); + const screen = screenBoxOf(sel.활성.r, sel.활성.c); + void active; + if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) { + return { kind: "fillHandle", r: sel.활성.r, c: sel.활성.c }; + } + } + return { kind: "cell", r, c }; + } + + function reveal(r: number, c: number): void { + if (r >= frozenRows) { + const y0 = rowGeo.offset(r) - rowGeo.offset(frozenRows); + const y1 = y0 + rowGeo.size(r); + const viewTop = paneBR.scrollTop; + const viewBottom = viewTop + paneBR.clientHeight; + if (y0 < viewTop) paneBR.scrollTop = y0; + else if (y1 > viewBottom) paneBR.scrollTop = y1 - paneBR.clientHeight; + } + if (c >= frozenCols) { + const x0 = colGeo.offset(c) - colGeo.offset(frozenCols); + const x1 = x0 + colGeo.size(c); + const viewLeft = paneBR.scrollLeft; + const viewRight = viewLeft + paneBR.clientWidth; + if (x0 < viewLeft) paneBR.scrollLeft = x0; + else if (x1 > viewRight) paneBR.scrollLeft = x1 - paneBR.clientWidth; + } + } + + function visibleRange(): CellRange { + const scrollW = Math.max(0, root.clientWidth - HEAD_COL_W - frozenW); + const scrollH = Math.max(0, root.clientHeight - HEAD_ROW_H - frozenH); + return { + r0: rowGeo.indexAt(paneBR.scrollTop), + c0: colGeo.indexAt(paneBR.scrollLeft), + r1: Math.min(extent.rows - 1, rowGeo.indexAt(paneBR.scrollTop + scrollH)), + c1: Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW)), + }; + } + + function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } { + return { layer: overlay, box: screenBoxOf(r, c) }; + } + + function destroy(): void { + resize.disconnect(); + paneBR.removeEventListener("scroll", onScroll); + root.remove(); + } + + render(); + + return { + root, + render, + invalidate, + renderSelection, + setRefHighlights, + cellBox: boxOf, + hitTest, + reveal, + visibleRange, + editorSlot, + destroy, + }; +} + +function paneDiv(className: string): HTMLElement { + const node = document.createElement("div"); + node.className = className; + return node; +} + +function innerDiv(className: string): HTMLElement { + const node = document.createElement("div"); + node.className = className; + return node; } diff --git a/A00_Common/spreadsheet/spreadsheet_headers.ts b/A00_Common/spreadsheet/spreadsheet_headers.ts new file mode 100644 index 00000000..ac634b4f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_headers.ts @@ -0,0 +1,223 @@ +/* ============================================================================= + * spreadsheet_headers.ts (주인 C) + * 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시. + * `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만 + * 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움). + * 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`. + * ========================================================================== */ + +import { colIndex, colName } from "./spreadsheet_address"; +import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +export const DEFAULT_COL_CHARS = 8.43; +export const DEFAULT_ROW_PT = 15; +/** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */ +const CHAR_PX = 7; +const CHAR_PAD_PX = 5; +const PT_PX = 4 / 3; + +export const colCharsToPx = (chars: number): number => Math.round(chars * CHAR_PX + CHAR_PAD_PX); +export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX); +export const ptToPx = (pt: number): number => Math.round(pt * PT_PX); +export const pxToPt = (px: number): number => px / PT_PX; + +export const colLabel = (c: number): string => colName(c); +export const rowLabel = (r: number): string => String(r + 1); + +/** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */ +export interface LineGeometry { + /** 자리 `i` 폭(px) — 숨겼으면 0. */ + size(i: number): number; + /** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */ + offset(i: number): number; + /** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */ + total(count: number): number; + /** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */ + indexAt(px: number): number; +} + +class SparseGeometry implements LineGeometry { + private readonly idx: number[] = []; + private readonly prefix: number[] = []; + + constructor( + private readonly base: number, + overrides: Map, + ) { + const keys = [...overrides.keys()].sort((a, b) => a - b); + let acc = 0; + for (const k of keys) { + acc += overrides.get(k)! - base; + this.idx.push(k); + this.prefix.push(acc); + } + } + + /** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */ + private lowerBound(i: number): number { + let lo = 0; + let hi = this.idx.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (this.idx[mid] < i) lo = mid + 1; + else hi = mid; + } + return lo; + } + + size(i: number): number { + const at = this.lowerBound(i); + if (this.idx[at] === i) { + const before = at > 0 ? this.prefix[at - 1] : 0; + return this.base + (this.prefix[at] - before); + } + return this.base; + } + + offset(i: number): number { + const at = this.lowerBound(i); + const acc = at > 0 ? this.prefix[at - 1] : 0; + return i * this.base + acc; + } + + total(count: number): number { + return this.offset(count); + } + + indexAt(px: number): number { + if (px <= 0) return 0; + let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2); + while (this.offset(hi) <= px) hi *= 2; + let lo = 0; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.offset(mid) <= px) lo = mid; + else hi = mid - 1; + } + return lo; + } +} + +export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry { + const base = colCharsToPx(book.기본?.열폭 ?? DEFAULT_COL_CHARS); + const overrides = new Map(); + for (const [key, info] of Object.entries(sheet.열 ?? {})) { + const c = colIndex(key); + if (c < 0) continue; + if (info.숨김) overrides.set(c, 0); + else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭)); + } + return new SparseGeometry(base, overrides); +} + +export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry { + const base = ptToPx(book.기본?.행높이 ?? DEFAULT_ROW_PT); + const overrides = new Map(); + for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) { + const r = Number(key) - 1; + if (!Number.isInteger(r) || r < 0) continue; + if (info.숨김) overrides.set(r, 0); + else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이)); + } + return new SparseGeometry(base, overrides); +} + +/** `from..to` 안에서 숨기지 않은 자리만. */ +export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] { + const list: number[] = []; + for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i); + return list; +} + +export interface HeaderCell { + el: HTMLElement; + index: number; +} + +/** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */ +export function makeColHeaderCell( + c: number, + x: number, + w: number, + hiddenBefore: boolean, +): HeaderCell { + const cellEl = document.createElement("div"); + cellEl.className = "aislo-grid__colhead"; + if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before"); + cellEl.style.left = `${x}px`; + cellEl.style.width = `${w}px`; + cellEl.dataset.col = String(c); + const label = document.createElement("span"); + label.className = "aislo-grid__head-label"; + label.textContent = colLabel(c); + cellEl.append(label); + const handle = document.createElement("div"); + handle.className = "aislo-grid__resize aislo-grid__resize--col"; + cellEl.append(handle); + return { el: cellEl, index: c }; +} + +/** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */ +export function makeRowHeaderCell( + r: number, + y: number, + h: number, + hiddenBefore: boolean, +): HeaderCell { + const cellEl = document.createElement("div"); + cellEl.className = "aislo-grid__rowhead"; + if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before"); + cellEl.style.top = `${y}px`; + cellEl.style.height = `${h}px`; + cellEl.dataset.row = String(r); + const label = document.createElement("span"); + label.className = "aislo-grid__head-label"; + label.textContent = rowLabel(r); + cellEl.append(label); + const handle = document.createElement("div"); + handle.className = "aislo-grid__resize aislo-grid__resize--row"; + cellEl.append(handle); + return { el: cellEl, index: r }; +} + +export interface ResizeDragOptions { + handle: HTMLElement; + axis: "col" | "row"; + currentPx: () => number; + onPreview: (px: number) => void; + onCommit: (px: number) => void; +} + +/** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */ +export function startDragResize(opts: ResizeDragOptions): void { + opts.handle.addEventListener("pointerdown", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const startClient = opts.axis === "col" ? ev.clientX : ev.clientY; + const startPx = opts.currentPx(); + opts.handle.setPointerCapture(ev.pointerId); + const next = (e: PointerEvent): number => { + const cur = opts.axis === "col" ? e.clientX : e.clientY; + return Math.max(4, startPx + (cur - startClient)); + }; + const move = (e: PointerEvent): void => opts.onPreview(next(e)); + const up = (e: PointerEvent): void => { + opts.handle.removeEventListener("pointermove", move); + opts.handle.removeEventListener("pointerup", up); + opts.onCommit(next(e)); + }; + opts.handle.addEventListener("pointermove", move); + opts.handle.addEventListener("pointerup", up); + }); +} + +/** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */ +export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void { + ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) }); +} + +/** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */ +export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void { + ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) }); +} From bfd71238297ea9a4471a51716b399cb7f91579ba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:33:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(spreadsheet):=20E=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20=EB=AA=A8=EC=9D=8C=20=C2=B7=20=EC=8B=9C=ED=8A=B8=20=ED=83=AD?= =?UTF-8?q?=20=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,
+  },
+};

From 48a213513217d5663a8932b61127b60be8ef487a Mon Sep 17 00:00:00 2001
From: umsangdon 
Date: Sun, 27 Sep 2026 20:33:55 +0900
Subject: [PATCH 3/3] =?UTF-8?q?feat(spreadsheet):=20D=20=EC=9E=87=EA=B8=B0?=
 =?UTF-8?q?=20=E2=80=94=20=EC=A7=84=EC=A7=9C=20A=20=EC=97=94=EC=A7=84?=
 =?UTF-8?q?=EA=B3=BC=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20=C2=B7=20inspect=20?=
 =?UTF-8?q?=EC=84=A0=ED=83=9D(window.=5F=5FaisloSheet=20=C2=B7=20=EC=9E=84?=
 =?UTF-8?q?=EC=8B=9C)=20=C2=B7=20=EC=8B=9C=ED=97=98=20=ED=8B=80=20?=
 =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- spreadsheet.ts — `inspect` 선택이면 문맥을 window.__aisloSheet 에 걺(M02 [스프레드시트 시험] 단추 전용 · destroy 때 뗌 · 1단계 검증 뒤 지움)
- 시험 틀 — ssScenario()(페이지 안 한 번에 한글 · 가리키기 · 재계산 · 되돌리기 · Delete · Ctrl+Enter · 수식 입력줄 · F2) · 가짜 격자 data-addr · 진짜 분수 값 보임 · 오류 모음

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
---
 A00_Common/spreadsheet/spreadsheet.ts         |  12 ++
 .../spreadsheet/harness_spreadsheet_d.cjs     | 108 ++++++++++++++++++
 .../spreadsheet/harness_spreadsheet_d.html    |   6 +
 .../spreadsheet/spreadsheet_d_fakes.cjs       |   9 +-
 4 files changed, 133 insertions(+), 2 deletions(-)

diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts
index 061d08b7..25f43b0e 100644
--- a/A00_Common/spreadsheet/spreadsheet.ts
+++ b/A00_Common/spreadsheet/spreadsheet.ts
@@ -48,6 +48,16 @@ export interface SpreadsheetOptions {
   onChange?: (book: Workbook) => void;
   /** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
   onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
+  /** ⚠ 임시 — 브레인 브라우저 검증용: 문맥을 `window.__aisloSheet` 에 걺(book · 고름 · 명령 읽기).
+   * M02 [스프레드시트 시험] 단추만 켬 · 산출근거 카드는 안 켬 · 1단계 검증 뒤 지움. */
+  inspect?: boolean;
+}
+
+declare global {
+  interface Window {
+    /** ⚠ 임시 — `inspect` 로 연 표의 문맥(브레인 검증용) */
+    __aisloSheet?: SpreadsheetContext;
+  }
 }
 
 export interface SpreadsheetHandle {
@@ -139,6 +149,7 @@ export function createSpreadsheet(
 
   grid.render();
   select(ctx.selection);
+  if (opts.inspect) window.__aisloSheet = ctx; // ⚠ 임시 — 브레인 검증용
 
   function notify(message: string): void {
     toast.textContent = message;
@@ -244,6 +255,7 @@ export function createSpreadsheet(
       return ctx.engine.snapshot();
     },
     destroy() {
+      if (window.__aisloSheet === ctx) delete window.__aisloSheet;
       clearTimeout(toastTimer);
       detachKeys();
       detachMouse();
diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.cjs b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs
index 8d220b14..01fee11f 100644
--- a/resources/tester/spreadsheet/harness_spreadsheet_d.cjs
+++ b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs
@@ -10,8 +10,116 @@ window.ssSample = JSON.parse(JSON.stringify(sample));
 window.ss = createSpreadsheet(host, JSON.parse(JSON.stringify(sample)), {
   onChange: () => window.ssChanges++,
   onSelect: (s) => (window.ssSelect = s),
+  inspect: true,
 });
 window.ssCreate = createSpreadsheet;
 /** 칸 입력 값(A1) */
 window.ssCell = (a1, sheet = "s1") =>
   window.ss.getDoc().시트.find((s) => s.id === sheet).칸[a1] ?? null;
+
+/* 한 번에 도는 시나리오 — 탭이 새로 읽혀도 끊기지 않게 페이지 안에서 합성 사건으로 조작하고 수치를 모음.
+ * (믿음 입력 — 진짜 키 · 마우스 · 한글 — 은 ORCA keypress · type · mouse 로 따로 잼) */
+window.ssScenario = () => {
+  const ta = () => document.querySelector(".ss-input");
+  const at = (a1) => {
+    const d = document.querySelector(`[data-a1="${a1}"]`).getBoundingClientRect();
+    return {
+      clientX: d.left + d.width / 2,
+      clientY: d.top + d.height / 2,
+      bubbles: true,
+      button: 0,
+    };
+  };
+  const grid = () => document.querySelector(".ss-grid-host");
+  const click = (a1, extra = {}) => {
+    grid().dispatchEvent(new MouseEvent("mousedown", { ...at(a1), ...extra }));
+    window.dispatchEvent(new MouseEvent("mouseup", at(a1)));
+  };
+  const dragTo = (from, to) => {
+    grid().dispatchEvent(new MouseEvent("mousedown", from));
+    window.dispatchEvent(new MouseEvent("mousemove", to));
+    window.dispatchEvent(new MouseEvent("mouseup", to));
+  };
+  const key = (k, o = {}) =>
+    document.activeElement.dispatchEvent(
+      new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...o }),
+    );
+  const type = (text) => {
+    const t = document.activeElement;
+    t.setRangeText(text, t.selectionStart, t.selectionEnd, "end");
+    t.dispatchEvent(new Event("input", { bubbles: true }));
+  };
+  const shown = (a1) => document.querySelector(`[data-a1="${a1}"]`).textContent;
+  const sel = () => `${ssSelect.칸}|${ssSelect.범위}`;
+  const out = {};
+  // 한글 첫 글자 — compositionstart 로 편집 시작 · 같은 textarea 그대로
+  click("L2");
+  ta().dispatchEvent(new CompositionEvent("compositionstart", { data: "" }));
+  out.imeEditing =
+    document.querySelector(".ss").classList.contains("ss--editing") &&
+    document.activeElement === ta();
+  type("한");
+  key("Enter");
+  out.ime = [ssCell("L2"), sel()];
+  // 수 · 식 · 참조 가리키기(방향키 + 누르기) · 재계산
+  click("L3");
+  type("2");
+  key("Enter");
+  type("=");
+  key("ArrowUp");
+  out.pointKey = ta().value;
+  type("*");
+  click("J5");
+  out.pointClick = [
+    ta().value,
+    [...document.querySelectorAll(".fg-ref")].map((x) => x.dataset.ref),
+  ];
+  key("Enter");
+  out.calc = [ssCell("L4"), shown("L4")];
+  click("L3");
+  type("5");
+  key("Enter");
+  out.recalc = shown("L4");
+  // 되돌리기 · 다시
+  key("z", { ctrlKey: true });
+  out.undo = [ssCell("L3"), shown("L4")];
+  key("y", { ctrlKey: true });
+  out.redo = [ssCell("L3"), shown("L4")];
+  // Delete(서식 남김) · 되돌림
+  click("B4");
+  key("Delete");
+  out.del = ssCell("B4");
+  key("z", { ctrlKey: true });
+  out.delUndo = ssCell("B4");
+  // 병합 · Shift/Ctrl 방향
+  click("A4");
+  key("ArrowRight", { shiftKey: true });
+  key("ArrowRight", { shiftKey: true });
+  out.shift = sel();
+  click("A3");
+  key("ArrowDown", { ctrlKey: true });
+  out.ctrl = sel();
+  // 끌어 고르기 · Ctrl+Enter
+  dragTo(at("P2"), at("Q3"));
+  out.drag = sel();
+  type("=L3+1");
+  key("Enter", { ctrlKey: true });
+  out.ctrlEnter = ["P2", "Q2", "P3", "Q3"].map((k) => ssCell(k)?.식);
+  // 수식 입력줄로 고치기
+  click("N2");
+  const bar = document.querySelector(".ss-formula");
+  bar.focus();
+  bar.value = "=J4*10";
+  bar.dispatchEvent(new Event("input", { bubbles: true }));
+  key("Enter");
+  out.bar = [ssCell("N2"), shown("N2"), sel()];
+  // F2 — 방향키가 글자 자리만
+  click("N2");
+  key("F2");
+  const before = sel();
+  key("ArrowLeft");
+  out.f2 = [before === sel(), document.querySelector(".ss").classList.contains("ss--editing")];
+  key("Escape");
+  out.errors = window.ssErrors;
+  return JSON.stringify(out);
+};
diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.html b/resources/tester/spreadsheet/harness_spreadsheet_d.html
index be9d4089..19349816 100644
--- a/resources/tester/spreadsheet/harness_spreadsheet_d.html
+++ b/resources/tester/spreadsheet/harness_spreadsheet_d.html
@@ -23,6 +23,12 @@
   
     
+