diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index be44d1bc..9e65764e 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -8,47 +8,31 @@ * 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) · * 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만). * 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`). + * 칸 그리기 · 넘침은 `spreadsheet_grid_cells.ts` · 행열 머리 · 윤곽은 `spreadsheet_grid_heads.ts`. * ========================================================================== */ import "./spreadsheet_grid.css"; -import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; -import { colIndex, colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; -import { - applyCellStyle, - cellStyleAt, - isAutoInk, - resolveBoxBorders, - setCellText, - type ResolvedBorders, -} from "./spreadsheet_cellstyle"; +import { colIndex, toA1 } from "./spreadsheet_address"; import { buildColGeometry, buildOutlineRuns, buildRowGeometry, - commitColWidth, - commitRowHeight, - isExplicitHidden, - makeColHeaderCell, - makeRowHeaderCell, - startDragResize, visibleIndices, type LineGeometry, type OutlineRun, } from "./spreadsheet_headers"; -import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt"; -import { formatValue } from "./spreadsheet_numfmt"; -import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill"; +import { + buildMergeMap, + computeExtent, + createPool, + renderCells, + scaledGeometry, + type CellPool, + type GridView, +} from "./spreadsheet_grid_cells"; +import { createHeads, HEAD_COL_W_BASE, HEAD_ROW_H_BASE } from "./spreadsheet_grid_heads"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; -import type { - CellRange, - CellStyle, - ErrorValue, - HAlign, - Scalar, - Sheet, - SheetCellAddress, - Workbook, -} from "./spreadsheet_types"; +import type { CellRange, SheetCellAddress } from "./spreadsheet_types"; import type { CellBox, GridHandle, @@ -57,172 +41,12 @@ import type { SpreadsheetContext, } from "./spreadsheet_view_types"; -const HEAD_ROW_H_BASE = 24; -const HEAD_COL_W_BASE = 46; const OUTLINE_GUTTER_BASE = 14; const RESIZE_HOTZONE = 4; const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"]; const ZOOM_MIN = 0.1; const ZOOM_MAX = 4; -/** 칸 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; -} - -/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */ -let measureRuler: CanvasRenderingContext2D | null = null; - -/** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */ -const SPILL_ALIGN: Partial> = { - general: "left", - left: "left", - right: "right", - center: "center", -}; - -function resetSpill(cell: HTMLElement): void { - cell.classList.remove("is-spill", "is-spill-r"); - const t = cell.firstElementChild as HTMLElement | null; - if (t) t.style.width = t.style.marginLeft = t.style.clipPath = ""; -} - -function measureText(text: string, font: string): number { - measureRuler ??= document.createElement("canvas").getContext("2d"); - if (!measureRuler) return text.length * 7; - measureRuler.font = font; - return measureRuler.measureText(text).width; -} - -/** 칸 글꼴 CSS 글(캔버스 재기용) — `applyCellStyle` 이 칸에 입히는 글꼴과 같은 차례로 고름. */ -function cellFont(style: CellStyle, book: Workbook, scale: number): string { - const family = style.글꼴 || book.기본?.글꼴 || "굴림"; - const size = (style.크기 ?? book.기본?.크기 ?? 10) * scale; - const weight = style.굵게 ? "700" : "400"; - const italic = style.기울임 ? "italic " : ""; - return `${italic}${weight} ${size}pt ${family}`; -} - -function scaledGeometry(base: LineGeometry, factor: number): LineGeometry { - if (factor === 1) return base; - return { - size: (i) => base.size(i) * factor, - offset: (i) => base.offset(i) * factor, - total: (i) => base.total(i) * factor, - indexAt: (px) => base.indexAt(px / factor), - }; -} - -interface CellDisplay { - text: string; - hAlign: HAlign; - title: string; - isError: boolean; - isNumeric: boolean; - color?: string; -} - -/** 칸 하나 값 — 식이면 엔진 값 · 아니면 입력 값(수는 Frac로) · 둘 다 없으면 빈 칸. */ -function scalarAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): Scalar { - const cell = sheet.칸[toA1(r, c)]; - return cell?.식 !== undefined - ? ctx.engine.value(sheet.id, r, c) - : cell?.값 !== undefined - ? toScalar(cell.값) - : null; -} - -/** 조건부 서식(sub4) 이 값을 보는 형 — Frac 는 근사 수로(조건 비교용 · 오류는 조건 대상 아님). */ -function toCondFmtValue(scalar: Scalar): CondFmtValue { - if (scalar === null || typeof scalar === "boolean" || typeof scalar === "string") return scalar; - if ("error" in scalar) return null; - return Number(scalar.n) / Number(scalar.d); -} - -function cellDisplay( - ctx: SpreadsheetContext, - sheet: Sheet, - r: number, - c: number, - hAlign0: HAlign, - code?: string, -): CellDisplay { - const scalar = scalarAt(ctx, sheet, r, c); - 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.글) : ""; - const isNumeric = - !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; - return { text: formatted.글, hAlign, title, isError, isNumeric, color: formatted.색 }; -} - export function createGrid(ctx: SpreadsheetContext): GridHandle { const root = document.createElement("div"); root.className = "aislo-grid"; @@ -296,6 +120,47 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { colOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0; const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom) + colGutterH(); const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom) + rowGutterW(); + const view: GridView = { + get colGeo() { + return colGeo; + }, + get rowGeo() { + return rowGeo; + }, + get merges() { + return merges; + }, + get extent() { + return extent; + }, + get zoom() { + return zoom; + }, + get frozenRows() { + return frozenRows; + }, + get frozenCols() { + return frozenCols; + }, + get rowOutlineRuns() { + return rowOutlineRuns; + }, + get colOutlineRuns() { + return colOutlineRuns; + }, + rowGutterW, + colGutterH, + }; + const { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline } = createHeads( + ctx, + view, + { + colFrozen: colHeadInnerFrozen, + colScroll: colHeadInnerScroll, + rowFrozen: rowHeadInnerFrozen, + rowScroll: rowHeadInnerScroll, + }, + ); const resize = new ResizeObserver(() => layoutPanes()); resize.observe(root); @@ -420,279 +285,6 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { 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(); - // 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서) - const slots = new Map>(); - const longs: { - r: number; - c: number; - el: HTMLElement; - own: number; - need: number; - align: SpillAlign; - }[] = []; - 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 addr = toA1(r0, c0); - cellEl.dataset.addr = addr; - const baseStyle = cellStyleAt(book, sheet, r0, c0); - const condStyle = sheet.조건부서식?.length - ? resolveCondFmtStyle(sheet, r0, c0, { - value: (rr, cc) => toCondFmtValue(scalarAt(ctx, sheet, rr, cc)), - }) - : null; - const style = condStyle ? { ...baseStyle, ...condStyle } : baseStyle; - 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, zoom); - // 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색. - if (display.color && !condStyle?.글자색 && !isAutoInk(display.color)) - cellEl.style.color = display.color; - const comment = sheet.comments?.[addr]; - // 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표. - let text = display.text; - if (display.isNumeric && !style.줄바꿈) { - const font = cellFont(style, book, zoom); - const avail = w - 4; - if (measureText(text, font) > avail) { - const hashW = measureText("#", font) || 1; - text = "#".repeat(Math.max(1, Math.floor(avail / hashW))); - } - } - setCellText(cellEl, text, display.title || comment || undefined); - resetSpill(cellEl); - if (!merge) { - (slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" }); - const align = SPILL_ALIGN[display.hAlign]; - if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) { - // 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로 - const need = measureText(text, cellFont(style, book, zoom)) + 4; - if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align }); - } - } - cellEl.classList.toggle("is-error", display.isError); - cellEl.classList.toggle("has-comment", !!comment); - cellEl.classList.toggle( - "is-selected", - ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0, - ); - } - } - // 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산 - for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5; - for (const l of longs) { - const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align); - if (!sp) continue; - l.el.classList.add("is-spill"); - const t = l.el.firstElementChild as HTMLElement; - t.style.width = `calc(100% + ${sp.left + sp.right}px)`; - t.style.marginLeft = `${-sp.left}px`; - if (sp.clipLeft || sp.clipRight) - t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`; - for (let k = sp.from; k < sp.to; k++) - pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r"); - } - poolSweep(pool, keep); - } - - /** 건너뛴 자리(prev+1..cur-1) 안에 사용자 숨김 · 필터 걸러짐이 있나(묶음 접힘은 빼고 — 화면 표시가 다름). */ - function colHiddenGap(sheet: Sheet, prev: number, cur: number): boolean { - for (let i = prev + 1; i < cur; i += 1) - if (isExplicitHidden(sheet.열?.[colName(i)])) return true; - return false; - } - function rowHiddenGap(sheet: Sheet, prev: number, cur: number): boolean { - for (let i = prev + 1; i < cur; i += 1) - if (isExplicitHidden(sheet.행?.[String(i + 1)])) return true; - return false; - } - - function layoutColHeaders( - cols: number[], - pool: CellPool, - container: HTMLElement, - base: number, - ): void { - const sheet = ctx.sheet(); - const keep = new Set(); - const top = colGutterH(); - const height = Math.round(HEAD_ROW_H_BASE * zoom); - 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 && colHiddenGap(sheet, prev, c); - 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.top = `${top}px`; - cellEl.style.width = `${colGeo.size(c)}px`; - cellEl.style.height = `${height}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 sheet = ctx.sheet(); - const keep = new Set(); - const left = rowGutterW(); - const width = Math.round(HEAD_COL_W_BASE * zoom); - 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 && rowHiddenGap(sheet, prev, r); - 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.left = `${left}px`; - cellEl.style.height = `${rowGeo.size(r)}px`; - cellEl.style.width = `${width}px`; - cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore); - }); - poolSweep(pool, keep); - } - - /** 묶음 접힘 뒤집기(같은 묶음 자리 모두) → `묶음설정` dispatch. */ - function outlineToggle(axis: "행" | "열", run: OutlineRun): void { - const idx: number[] = []; - for (let i = run.from; i <= run.to; i += 1) idx.push(i); - ctx.dispatch({ - 종류: "묶음설정", - 시트: ctx.sheet().id, - 축: axis, - 번호: idx, - 묶음: { 층: 1, 접힘: !run.collapsed }, - }); - } - - /** 윤곽 +/- 단추 — 행 묶음 마지막 다음 자리(머리 왼쪽 띠) · 열 묶음도 같은 이치(머리 위 띠). - * 여러 층(중첩)은 1단계 다음(지금은 늘 층 1). */ - function renderRowOutline(): void { - rowHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); - rowHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); - if (!rowOutlineRuns.length) return; - const gw = rowGutterW(); - for (const run of rowOutlineRuns) { - const btnRow = Math.min(run.to + 1, extent.rows - 1); - const container = btnRow < frozenRows ? rowHeadInnerFrozen : rowHeadInnerScroll; - const base = btnRow < frozenRows ? 0 : rowGeo.offset(frozenRows); - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "aislo-grid__outline-btn"; - btn.textContent = run.collapsed ? "+" : "−"; - btn.style.top = `${rowGeo.offset(btnRow) - base}px`; - btn.style.left = "0"; - btn.style.width = `${gw}px`; - btn.style.height = `${rowGeo.size(btnRow) || Math.round(HEAD_ROW_H_BASE * zoom)}px`; - btn.addEventListener("click", (ev) => { - ev.stopPropagation(); - outlineToggle("행", run); - }); - container.append(btn); - } - } - - function renderColOutline(): void { - colHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); - colHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); - if (!colOutlineRuns.length) return; - const gh = colGutterH(); - for (const run of colOutlineRuns) { - const btnCol = Math.min(run.to + 1, extent.cols - 1); - const container = btnCol < frozenCols ? colHeadInnerFrozen : colHeadInnerScroll; - const base = btnCol < frozenCols ? 0 : colGeo.offset(frozenCols); - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "aislo-grid__outline-btn"; - btn.textContent = run.collapsed ? "+" : "−"; - btn.style.left = `${colGeo.offset(btnCol) - base}px`; - btn.style.top = "0"; - btn.style.width = `${colGeo.size(btnCol) || Math.round(HEAD_COL_W_BASE * zoom)}px`; - btn.style.height = `${gh}px`; - btn.addEventListener("click", (ev) => { - ev.stopPropagation(); - outlineToggle("열", run); - }); - container.append(btn); - } - } - function renderVisible(): void { root.classList.toggle("aislo-grid--gridlines", gridlines()); const w = root.clientWidth; @@ -736,8 +328,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { renderRowOutline(); renderColOutline(); - renderRowOfCells(frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0); - renderRowOfCells( + renderCells(ctx, view, frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0); + renderCells( + ctx, + view, frozenRowsVisible, scrollColsVisible, pools.tr, @@ -745,7 +339,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { colGeo.offset(frozenCols), 0, ); - renderRowOfCells( + renderCells( + ctx, + view, scrollRowsVisible, frozenColsVisible, pools.bl, @@ -753,7 +349,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { 0, rowGeo.offset(frozenRows), ); - renderRowOfCells( + renderCells( + ctx, + view, scrollRowsVisible, scrollColsVisible, pools.br, diff --git a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts new file mode 100644 index 00000000..bd26acf4 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts @@ -0,0 +1,322 @@ +/* ============================================================================= + * spreadsheet_grid_cells.ts + * 격자 칸 그리기 — 판 하나(틀고정 4 판 중)의 보이는 칸을 div 로 그림(칸 재사용 `CellPool`) · + * 값 → 글(`formatValue`) · 서식 CSS · 수 `#####` · 긴 글 넘침(`spreadsheet_spill.ts`) 붙이기. + * `spreadsheet_grid.ts` 가 판마다 부름 — 자리 재기 · 병합 · 확대는 `GridView` 로 받음. + * ========================================================================== */ + +import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; +import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { + applyCellStyle, + cellStyleAt, + isAutoInk, + resolveBoxBorders, + setCellText, + type ResolvedBorders, +} from "./spreadsheet_cellstyle"; +import type { LineGeometry, OutlineRun } from "./spreadsheet_headers"; +import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt"; +import { formatValue } from "./spreadsheet_numfmt"; +import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { + CellRange, + CellStyle, + ErrorValue, + HAlign, + Scalar, + Sheet, + Workbook, +} from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +/** 격자가 지금 쓰는 자리 재기 · 병합 · 틀고정 — `createGrid` 의 값을 읽기만(바뀌면 새 값이 보임). */ +export interface GridView { + readonly colGeo: LineGeometry; + readonly rowGeo: LineGeometry; + readonly merges: Map; + readonly extent: { rows: number; cols: number }; + readonly zoom: number; + readonly frozenRows: number; + readonly frozenCols: number; + readonly rowOutlineRuns: OutlineRun[]; + readonly colOutlineRuns: OutlineRun[]; + rowGutterW(): number; + colGutterH(): number; +} + +/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */ +export interface CellPool { + used: Map; + free: HTMLElement[]; +} +export 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; +} + +export 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; +} + +/** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */ +export 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)), + }; +} + +export 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; +} + +const SPILL_ALIGN: Partial> = { + general: "left", + left: "left", + right: "right", + center: "center", +}; + +function resetSpill(cell: HTMLElement): void { + cell.classList.remove("is-spill", "is-spill-r"); + const t = cell.firstElementChild as HTMLElement | null; + if (t) t.style.width = t.style.marginLeft = t.style.clipPath = ""; +} + +let measureRuler: CanvasRenderingContext2D | null = null; + +/** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */ +function measureText(text: string, font: string): number { + measureRuler ??= document.createElement("canvas").getContext("2d"); + if (!measureRuler) return text.length * 7; + measureRuler.font = font; + return measureRuler.measureText(text).width; +} + +/** 칸 글꼴 CSS 글(캔버스 재기용) — `applyCellStyle` 이 칸에 입히는 글꼴과 같은 차례로 고름. */ +function cellFont(style: CellStyle, book: Workbook, scale: number): string { + const family = style.글꼴 || book.기본?.글꼴 || "굴림"; + const size = (style.크기 ?? book.기본?.크기 ?? 10) * scale; + const weight = style.굵게 ? "700" : "400"; + const italic = style.기울임 ? "italic " : ""; + return `${italic}${weight} ${size}pt ${family}`; +} + +/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */ +export function scaledGeometry(base: LineGeometry, factor: number): LineGeometry { + if (factor === 1) return base; + return { + size: (i) => base.size(i) * factor, + offset: (i) => base.offset(i) * factor, + total: (i) => base.total(i) * factor, + indexAt: (px) => base.indexAt(px / factor), + }; +} + +interface CellDisplay { + text: string; + hAlign: HAlign; + title: string; + isError: boolean; + isNumeric: boolean; + color?: string; +} + +/** 칸 하나 값 — 식이면 엔진 값 · 아니면 입력 값(수는 Frac로) · 둘 다 없으면 빈 칸. */ +function scalarAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): Scalar { + const cell = sheet.칸[toA1(r, c)]; + return cell?.식 !== undefined + ? ctx.engine.value(sheet.id, r, c) + : cell?.값 !== undefined + ? toScalar(cell.값) + : null; +} + +/** 조건부 서식(sub4) 이 값을 보는 형 — Frac 는 근사 수로(조건 비교용 · 오류는 조건 대상 아님). */ +function toCondFmtValue(scalar: Scalar): CondFmtValue { + if (scalar === null || typeof scalar === "boolean" || typeof scalar === "string") return scalar; + if ("error" in scalar) return null; + return Number(scalar.n) / Number(scalar.d); +} + +function cellDisplay( + ctx: SpreadsheetContext, + sheet: Sheet, + r: number, + c: number, + hAlign0: HAlign, + code?: string, +): CellDisplay { + const scalar = scalarAt(ctx, sheet, r, c); + 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.글) : ""; + const isNumeric = + !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; + return { text: formatted.글, hAlign, title, isError, isNumeric, color: formatted.색 }; +} + +/** 판 하나의 보이는 칸 — 병합 칸은 div 하나 · 안 보이게 된 칸은 떼어 둠(다음에 다시 씀). */ +export function renderCells( + ctx: SpreadsheetContext, + view: GridView, + rowsVisible: number[], + colsVisible: number[], + pool: CellPool, + container: HTMLElement, + baseX: number, + baseY: number, +): void { + const { colGeo, rowGeo, merges, extent, zoom } = view; + const sheet = ctx.sheet(); + const book = ctx.book; + const keep = new Set(); + // 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서) + const slots = new Map>(); + const longs: { + r: number; + c: number; + el: HTMLElement; + own: number; + need: number; + align: SpillAlign; + }[] = []; + 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 addr = toA1(r0, c0); + cellEl.dataset.addr = addr; + const baseStyle = cellStyleAt(book, sheet, r0, c0); + const condStyle = sheet.조건부서식?.length + ? resolveCondFmtStyle(sheet, r0, c0, { + value: (rr, cc) => toCondFmtValue(scalarAt(ctx, sheet, rr, cc)), + }) + : null; + const style = condStyle ? { ...baseStyle, ...condStyle } : baseStyle; + 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, zoom); + // 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색. + if (display.color && !condStyle?.글자색 && !isAutoInk(display.color)) + cellEl.style.color = display.color; + const comment = sheet.comments?.[addr]; + // 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표. + let text = display.text; + if (display.isNumeric && !style.줄바꿈) { + const font = cellFont(style, book, zoom); + const avail = w - 4; + if (measureText(text, font) > avail) { + const hashW = measureText("#", font) || 1; + text = "#".repeat(Math.max(1, Math.floor(avail / hashW))); + } + } + setCellText(cellEl, text, display.title || comment || undefined); + resetSpill(cellEl); + if (!merge) { + (slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" }); + const align = SPILL_ALIGN[display.hAlign]; + if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) { + // 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로 + const need = measureText(text, cellFont(style, book, zoom)) + 4; + if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align }); + } + } + cellEl.classList.toggle("is-error", display.isError); + cellEl.classList.toggle("has-comment", !!comment); + cellEl.classList.toggle( + "is-selected", + ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0, + ); + } + } + // 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산 + for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5; + for (const l of longs) { + const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align); + if (!sp) continue; + l.el.classList.add("is-spill"); + const t = l.el.firstElementChild as HTMLElement; + t.style.width = `calc(100% + ${sp.left + sp.right}px)`; + t.style.marginLeft = `${-sp.left}px`; + if (sp.clipLeft || sp.clipRight) + t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`; + for (let k = sp.from; k < sp.to; k++) pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r"); + } + poolSweep(pool, keep); +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid_heads.ts b/A00_Common/spreadsheet/spreadsheet_grid_heads.ts new file mode 100644 index 00000000..d4574f30 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_grid_heads.ts @@ -0,0 +1,200 @@ +/* ============================================================================= + * spreadsheet_grid_heads.ts + * 격자 머리 — 행 · 열 머리 칸(보이는 것만 · 폭 · 높이 끌기) · 숨긴 자리 표시 · 묶음 윤곽 +/- 단추. + * `spreadsheet_grid.ts` 가 판을 그릴 때 부름 — 머리 칸 모양 · 끌기는 `spreadsheet_headers.ts`. + * ========================================================================== */ + +import { colName } from "./spreadsheet_address"; +import { + commitColWidth, + commitRowHeight, + isExplicitHidden, + makeColHeaderCell, + makeRowHeaderCell, + startDragResize, + type OutlineRun, +} from "./spreadsheet_headers"; +import { poolSweep, type CellPool, type GridView } from "./spreadsheet_grid_cells"; +import type { Sheet } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +export const HEAD_ROW_H_BASE = 24; +export const HEAD_COL_W_BASE = 46; + +/** 머리 안쪽 층 넷 — 틀고정 쪽 · 스크롤 쪽. */ +export interface HeadLayers { + colFrozen: HTMLElement; + colScroll: HTMLElement; + rowFrozen: HTMLElement; + rowScroll: HTMLElement; +} + +export function createHeads(ctx: SpreadsheetContext, view: GridView, layers: HeadLayers) { + const { + colFrozen: colHeadInnerFrozen, + colScroll: colHeadInnerScroll, + rowFrozen: rowHeadInnerFrozen, + rowScroll: rowHeadInnerScroll, + } = layers; + + /** 건너뛴 자리(prev+1..cur-1) 안에 사용자 숨김 · 필터 걸러짐이 있나(묶음 접힘은 빼고 — 화면 표시가 다름). */ + function colHiddenGap(sheet: Sheet, prev: number, cur: number): boolean { + for (let i = prev + 1; i < cur; i += 1) + if (isExplicitHidden(sheet.열?.[colName(i)])) return true; + return false; + } + function rowHiddenGap(sheet: Sheet, prev: number, cur: number): boolean { + for (let i = prev + 1; i < cur; i += 1) + if (isExplicitHidden(sheet.행?.[String(i + 1)])) return true; + return false; + } + + function layoutColHeaders( + cols: number[], + pool: CellPool, + container: HTMLElement, + base: number, + ): void { + const sheet = ctx.sheet(); + const keep = new Set(); + const top = view.colGutterH(); + const height = Math.round(HEAD_ROW_H_BASE * view.zoom); + 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 && colHiddenGap(sheet, prev, c); + 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: () => view.colGeo.size(c), + onPreview: (px) => { + cellEl!.style.width = `${px}px`; + }, + onCommit: (px) => commitColWidth(ctx, c, px), + }); + } + cellEl.style.left = `${view.colGeo.offset(c) - base}px`; + cellEl.style.top = `${top}px`; + cellEl.style.width = `${view.colGeo.size(c)}px`; + cellEl.style.height = `${height}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 sheet = ctx.sheet(); + const keep = new Set(); + const left = view.rowGutterW(); + const width = Math.round(HEAD_COL_W_BASE * view.zoom); + 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 && rowHiddenGap(sheet, prev, r); + 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: () => view.rowGeo.size(r), + onPreview: (px) => { + cellEl!.style.height = `${px}px`; + }, + onCommit: (px) => commitRowHeight(ctx, r, px), + }); + } + cellEl.style.top = `${view.rowGeo.offset(r) - base}px`; + cellEl.style.left = `${left}px`; + cellEl.style.height = `${view.rowGeo.size(r)}px`; + cellEl.style.width = `${width}px`; + cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore); + }); + poolSweep(pool, keep); + } + + /** 묶음 접힘 뒤집기(같은 묶음 자리 모두) → `묶음설정` dispatch. */ + function outlineToggle(axis: "행" | "열", run: OutlineRun): void { + const idx: number[] = []; + for (let i = run.from; i <= run.to; i += 1) idx.push(i); + ctx.dispatch({ + 종류: "묶음설정", + 시트: ctx.sheet().id, + 축: axis, + 번호: idx, + 묶음: { 층: 1, 접힘: !run.collapsed }, + }); + } + + /** 윤곽 +/- 단추 — 행 묶음 마지막 다음 자리(머리 왼쪽 띠) · 열 묶음도 같은 이치(머리 위 띠). + * 여러 층(중첩)은 1단계 다음(지금은 늘 층 1). */ + function renderRowOutline(): void { + rowHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); + rowHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); + if (!view.rowOutlineRuns.length) return; + const gw = view.rowGutterW(); + for (const run of view.rowOutlineRuns) { + const btnRow = Math.min(run.to + 1, view.extent.rows - 1); + const container = btnRow < view.frozenRows ? rowHeadInnerFrozen : rowHeadInnerScroll; + const base = btnRow < view.frozenRows ? 0 : view.rowGeo.offset(view.frozenRows); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "aislo-grid__outline-btn"; + btn.textContent = run.collapsed ? "+" : "−"; + btn.style.top = `${view.rowGeo.offset(btnRow) - base}px`; + btn.style.left = "0"; + btn.style.width = `${gw}px`; + btn.style.height = `${view.rowGeo.size(btnRow) || Math.round(HEAD_ROW_H_BASE * view.zoom)}px`; + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + outlineToggle("행", run); + }); + container.append(btn); + } + } + + function renderColOutline(): void { + colHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); + colHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove()); + if (!view.colOutlineRuns.length) return; + const gh = view.colGutterH(); + for (const run of view.colOutlineRuns) { + const btnCol = Math.min(run.to + 1, view.extent.cols - 1); + const container = btnCol < view.frozenCols ? colHeadInnerFrozen : colHeadInnerScroll; + const base = btnCol < view.frozenCols ? 0 : view.colGeo.offset(view.frozenCols); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "aislo-grid__outline-btn"; + btn.textContent = run.collapsed ? "+" : "−"; + btn.style.left = `${view.colGeo.offset(btnCol) - base}px`; + btn.style.top = "0"; + btn.style.width = `${view.colGeo.size(btnCol) || Math.round(HEAD_COL_W_BASE * view.zoom)}px`; + btn.style.height = `${gh}px`; + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + outlineToggle("열", run); + }); + container.append(btn); + } + } + + return { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index b941b2a2..e5f12b99 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -41,7 +41,10 @@ * B spreadsheet_functions_misc.ts 논리 · 글 · 찾기 · 세기 ~500 * B spreadsheet_numfmt.ts 숫자 형식 코드 → 보이는 글 ~400 * B spreadsheet_fill.ts 채우기 핸들 연속값 ~200 - * C spreadsheet_grid.ts 격자 — 보이는 칸만 · 틀고정 4 판 · 병합 · 고름 그림 ~550 + * C spreadsheet_grid.ts 격자 — 보이는 칸만 · 틀고정 4 판 · 병합 · 고름 그림 ~650 + * C spreadsheet_grid_cells.ts 판 하나 칸 그리기 · 값 글 · #### · 넘침 붙이기 ~320 + * C spreadsheet_grid_heads.ts 행열 머리 칸 · 숨김 표시 · 묶음 윤곽 단추 ~200 + * C spreadsheet_spill.ts 긴 글 넘침 계산(옆 빈 칸) ~60 * C spreadsheet_headers.ts 열 글자 · 행 번호 · 폭 · 높이 끌기 · 숨김 표시 ~350 * C spreadsheet_cellstyle.ts 서식 → CSS(hair · 균등 분할 · 겹친 테두리) ~250 * C spreadsheet_grid.css ~200 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts index 82edc9ba..de5b8b01 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts @@ -52,11 +52,11 @@ const TEXT = { askOk: "새로 만듦", askCancel: "취소", askBody: - "이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로", + "이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로", askAlone: - "이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로", - made: "새 조합 {value} 을 만들고 이 열이 가리킴", - reused: "같은 내용의 조합 {value} 을 씀", + "이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로", + made: "새 {kind} {key} 「{name}」 을 만듦 — 이 열이 가리킴", + reused: "같은 내용의 {kind} {key} 「{name}」 을 씀 — 새로 안 만듦", failed: "조합을 못 고침 — {value}", }; @@ -128,9 +128,15 @@ export const branchName = ( ? original : `${original} · ${label}`; -/** 결과 알림 — 새로 만듦 / 같은 조합 씀 */ -export const branchToast = (r: BranchedCombo): void => - showToast(fill(r.새로만듦 ? TEXT.made : TEXT.reused, `${r.키} ${r.이름}`), "success"); +/** 결과 알림 — 새로 만듦 / 같은 내용의 있는 조합을 씀(`kind` = 일위대가 조합 · 자재 조합) */ +export const branchToast = (r: BranchedCombo, kind = "일위대가 조합"): void => + showToast( + (r.새로만듦 ? TEXT.made : TEXT.reused) + .replace("{kind}", kind) + .replace("{key}", r.키) + .replace("{name}", r.이름), + "success", + ); export interface ComboBarOptions { /** 집계표 열 id */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts index 33e65c6d..a6db42a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts @@ -47,7 +47,8 @@ const TEXT = { loadFailed: "자재 조합을 못 읽음 — {value}", askBody: "이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로", - askFirst: "이 열에 새 자재 조합을 만듦 — 담은 자재 목록으로 새 조합을 만들고 이 열이 가리킴", + askFirst: + "이 열에 새 자재 조합을 만듦 — 담은 자재 목록으로 새 조합을 만들고 이 열이 가리킴 · 같은 내용의 자재 조합이 있으면 그것을 씀", askAlone: "이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀", failed: "자재 조합을 못 고침 — {value}", @@ -290,7 +291,7 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { 담은자재: items, 이름: name, }); - branchToast(result); + branchToast(result, TEXT.combo); key = result.키; others = 0; combo = await fetchMaterialCombo(key).catch(() => null); diff --git a/resources/tester/test_sheet_f2_caret.py b/resources/tester/test_sheet_f2_caret.py new file mode 100644 index 00000000..ade04c09 --- /dev/null +++ b/resources/tester/test_sheet_f2_caret.py @@ -0,0 +1,24 @@ +"""표 부품(`ui_template_sheet.ts`) 칸 고치기 — 엑셀처럼 F2 는 커서를 글 끝에(이어 씀). + +화면 조작은 ORCA 로 봄 — 여기서는 키마다 고치기 방식이 안 섞이는지만 봄. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +SHEET = Path(__file__).resolve().parents[2] / "ui_template" / "sheet" / "ui_template_sheet.ts" + + +def test_F2_는_글_끝_바로_치기는_바꿈_두_번_누르기는_그대로(): + body = SHEET.read_text(encoding="utf-8") + # Enter · F2 = 글 끝 커서 + assert re.search( + r'event\.key === "F2"\) \{\s*event\.preventDefault\(\);\s*edit\(undefined, true\);', body + ) + # 바로 치기 = 누른 글자로 바꿈 + assert "edit(event.key);" in body + # 두 번 누르기 = 예전대로(글 전부 고름) + assert 'else if (target.closest("td[data-r]")) edit();' in body + assert "box.setSelectionRange(box.value.length, box.value.length)" in body diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 086840de..5728c0e5 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -244,7 +244,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got); root.focus({ preventScroll: true }); }; - const edit = (initial?: string): void => { + /** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */ + const edit = (initial?: string, atEnd = false): void => { const sel = state.sel; const col = sel && colOf(sel.c); if (!sel || !col) return; @@ -258,7 +259,9 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (!td) return; startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value)); const box = td.querySelector("input"); - if (box && initial !== undefined) box.value = initial; + if (!box) return; + if (initial !== undefined) box.value = initial; + if (initial !== undefined || atEnd) box.setSelectionRange(box.value.length, box.value.length); }; /** 층 이름(머리 층 줄 머리 칸) 고치기 — master */ const editLayer = (th: HTMLElement): void => { @@ -337,7 +340,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio move(0, event.shiftKey ? -1 : 1); } else if (event.key === "Enter" || event.key === "F2") { event.preventDefault(); - edit(); + edit(undefined, true); } else if (event.key === "Delete" || event.key === "Backspace") { const col = state.sel && colOf(state.sel.c); if (state.sel && col && keyEditable(state, state.sel.r, col)) { diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index bda8dae0..30fe7c41 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -10,6 +10,9 @@ * ========================================================================== */ :root { + /* 브라우저 기본 부품(체크 · 색 고르개 · 펼침 목록 · 스크롤 막대)이 테마를 따름 */ + color-scheme: light; + /* --------------------------------------------------------------------------- * 1. 색상 토큰 (Colors) — 바이올렛 단색 팔레트 * ------------------------------------------------------------------------ */ @@ -282,6 +285,7 @@ * 바이올렛 액센트는 명도를 올려 대비 확보. * ========================================================================== */ :root[data-theme="dark"] { + color-scheme: dark; --color-canvas: #16121f; --color-paper: #1e1930; --color-mist: #322a45; @@ -322,6 +326,7 @@ /* 시스템 설정 기반 자동 다크 (사용자가 명시적으로 data-theme를 안 준 경우) */ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { + color-scheme: dark; --color-canvas: #16121f; --color-paper: #1e1930; --color-mist: #322a45;