/* 스프레드시트 D 시험용 가짜 부품 — 남의 몫(A 엔진 · B 채우기 · C 격자 · E 도구)이 아직 빈 몸일 때만 * 번들 도우미(helper_spreadsheet_d_bundle.cjs)가 이것으로 바꿔 끼움. 진짜가 들어오면 안 씀. * 뜻은 엑셀 흉내의 최소 — D 입력(고름 · 편집 · 가리키기 · 채우기 끌기 · 되돌리기)을 재기 위한 것뿐. */ const MAX_ROWS = 1048576; const MAX_COLS = 16384; // ── @ui el ──────────────────────────────────────────────────────────────── function el(tag, o = {}) { const n = document.createElement(tag); if (o.className) n.className = o.className; if (o.text !== undefined) n.textContent = o.text; for (const [k, v] of Object.entries(o.attrs || {})) n.setAttribute(k, v); for (const c of o.children || []) n.append(c); return n; } // ── A 주소 ──────────────────────────────────────────────────────────────── function colName(c) { let s = ""; for (c += 1; c > 0; c = Math.floor((c - 1) / 26)) s = String.fromCharCode(65 + ((c - 1) % 26)) + s; return s; } function colIndex(t) { if (!/^[A-Za-z]{1,3}$/.test(t)) return -1; let n = 0; for (const ch of t.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64; return n - 1; } const toA1 = (r, c) => colName(c) + (r + 1); function parseA1(t) { const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(t.trim()); if (!m) return null; return { r: Number(m[2]) - 1, c: colIndex(m[1]) }; } function rangeToA1(g) { if (g.c0 === 0 && g.c1 === MAX_COLS - 1) return `${g.r0 + 1}:${g.r1 + 1}`; if (g.r0 === 0 && g.r1 === MAX_ROWS - 1) return `${colName(g.c0)}:${colName(g.c1)}`; if (g.r0 === g.r1 && g.c0 === g.c1) return toA1(g.r0, g.c0); return `${toA1(g.r0, g.c0)}:${toA1(g.r1, g.c1)}`; } function parseRange(t) { t = t.replace(/\$/g, "").trim(); const [a, b = a] = t.split(":"); const pa = parseA1(a); const pb = parseA1(b); if (pa && pb) 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), }; if (/^\d+$/.test(a) && /^\d+$/.test(b)) return { r0: Math.min(a, b) - 1, r1: Math.max(a, b) - 1, c0: 0, c1: MAX_COLS - 1 }; if (colIndex(a) >= 0 && colIndex(b) >= 0) return { r0: 0, r1: MAX_ROWS - 1, c0: Math.min(colIndex(a), colIndex(b)), c1: Math.max(colIndex(a), colIndex(b)), }; return null; } const address = { colName, colIndex, toA1, parseA1, rangeToA1, parseRange, quoteSheet: (n) => (/^[\w가-힣]+$/.test(n) ? n : `'${n.replace(/'/g, "''")}'`), cellId: (r, c) => r * MAX_COLS + c, fromCellId: (id) => ({ r: Math.floor(id / MAX_COLS), c: id % MAX_COLS }), }; // ── A 낱말 (참조만 정확히) ────────────────────────────────────────────────── const REF = /(?:(?:'(?:[^']|'')+'|[A-Za-z_가-힣][\w.가-힣]*)!)?\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?/y; function tokenize(f) { const out = []; let i = 0; while (i < f.length) { REF.lastIndex = i; const m = REF.exec(f); if (m && !/[A-Za-z0-9_]/.test(f[i - 1] || "") && f[i + m[0].length] !== "(") { out.push({ kind: "ref", text: m[0], start: i, end: i + m[0].length }); i += m[0].length; continue; } out.push({ kind: "unknown", text: f[i], start: i, end: i + 1 }); i++; } return out; } const parser = { tokenize, parseFormula: () => ({ ok: false, message: "가짜", at: 0 }), formulaToText: () => "", }; // ── A 참조 옮김 ──────────────────────────────────────────────────────────── function moveFormula(f, dr, dc) { return f.replace(/(\$?)([A-Z]{1,3})(\$?)(\d+)/g, (all, ac, col, ar, row) => { const c = ac ? colIndex(col) : colIndex(col) + dc; const r = ar ? Number(row) - 1 : Number(row) - 1 + dr; if (c < 0 || r < 0) return "#REF!"; return `${ac}${colName(c)}${ar}${r + 1}`; }); } const refshift = { moveFormula, shiftForCommand: (f) => f, r1c1ToA1: (f) => f }; // ── B 채우기 (복사 + 두 수 간격만) ───────────────────────────────────────── function fillCells(book, sheetId, src, target) { const sheet = book.시트.find((s) => s.id === sheetId); const out = {}; const vertical = target.r0 !== src.r0 || target.r1 !== src.r1; const len = vertical ? src.r1 - src.r0 + 1 : src.c1 - src.c0 + 1; for (let r = target.r0; r <= target.r1; r++) for (let c = target.c0; c <= target.c1; c++) { if (r >= src.r0 && r <= src.r1 && c >= src.c0 && c <= src.c1) continue; const k = vertical ? (((r - src.r0) % len) + len) % len : (((c - src.c0) % len) + len) % len; const sr = vertical ? src.r0 + k : r; const sc = vertical ? c : src.c0 + k; const cell = sheet.칸[toA1(sr, sc)]; if (!cell) { out[toA1(r, c)] = null; continue; } const next = { ...cell }; if (cell.식) next.식 = moveFormula(cell.식, r - sr, c - sc); // 수 두 칸 이상이면 간격 이음 const first = sheet.칸[toA1(vertical ? src.r0 : r, vertical ? c : src.c0)]; const second = sheet.칸[toA1(vertical ? src.r0 + 1 : r, vertical ? c : src.c0 + 1)]; if (len >= 2 && typeof first?.값 === "number" && typeof second?.값 === "number") { const step = second.값 - first.값; const n = vertical ? r - src.r0 : c - src.c0; next.값 = first.값 + step * n; } out[toA1(r, c)] = next; } return out; } const fill = { fillCells }; // ── A 명령 · 이력 (칸 · 내용지움 · 묶음) ──────────────────────────────────── class CommandError extends Error {} function cellsIn(sheet, ranges) { const keys = []; for (const key of Object.keys(sheet.칸)) { const a = parseA1(key); if (ranges.some((g) => a.r >= g.r0 && a.r <= g.r1 && a.c >= g.c0 && a.c <= g.c1)) keys.push(key); } return keys; } function applyCommand(book, cmd) { const sheetOf = (id) => book.시트.find((s) => s.id === id); if (cmd.종류 === "칸") { const sheet = sheetOf(cmd.시트); const undo = {}; const cells = []; for (const [k, v] of Object.entries(cmd.칸)) { undo[k] = sheet.칸[k] ? structuredClone(sheet.칸[k]) : null; if (v === null) delete sheet.칸[k]; else sheet.칸[k] = structuredClone(v); cells.push({ 시트: cmd.시트, ...parseA1(k) }); } return { undo: { 종류: "칸", 시트: cmd.시트, 칸: undo }, cells, rebuild: false }; } if (cmd.종류 === "내용지움") { const sheet = sheetOf(cmd.시트); const next = {}; for (const k of cellsIn(sheet, cmd.범위)) { const { 서식 } = sheet.칸[k]; next[k] = 서식 === undefined ? null : { 서식 }; } return applyCommand(book, { 종류: "칸", 시트: cmd.시트, 칸: next }); } if (cmd.종류 === "묶음") { const undos = []; const cells = []; for (const c of cmd.명령) { const e = applyCommand(book, c); undos.unshift(e.undo); cells.push(...e.cells); } return { undo: { 종류: "묶음", 명령: undos }, cells, rebuild: false }; } throw new CommandError(`가짜 명령 없음: ${cmd.종류}`); } const commands = { CommandError, applyCommand, emptyWorkbook: (col, name) => ({ 종류: "통합문서", 판: 1, 열: col, 서식: [{}], 시트: [{ id: "s1", 이름: name, 칸: {} }], }), emptySheet: (id, name) => ({ id, 이름: name, 칸: {} }), compactStyles: () => {}, }; const history = { createHistory(limit = 100) { let done = []; let undone = []; return { push(cmd, undo) { done.push({ cmd, undo }); if (done.length > limit) done.shift(); undone = []; }, undo() { const e = done.pop(); if (!e) return null; undone.push(e); return e.undo; }, redo() { const e = undone.pop(); if (!e) return null; done.push(e); return e.cmd; }, canUndo: () => done.length > 0, canRedo: () => undone.length > 0, clear() { done = []; undone = []; }, }; }, }; // ── A 계산 엔진 (사칙 · SUM 만 · double) ──────────────────────────────────── function createCalcEngine(book0) { let book = book0; const sheetOf = (id) => book.시트.find((s) => s.id === id); function value(sid, r, c, depth = 0) { const cell = sheetOf(sid)?.칸[toA1(r, c)]; if (!cell) return null; if (cell.식 === undefined) return cell.값 ?? null; if (depth > 50) return { error: "#CYCLE!" }; const num = (v) => (typeof v === "number" ? v : v === true ? 1 : 0); let f = cell.식.replace(/SUM\(([A-Z]+\d+):([A-Z]+\d+)\)/gi, (_, a, b) => { const g = parseRange(`${a}:${b}`); let s = 0; for (let rr = g.r0; rr <= g.r1; rr++) for (let cc = g.c0; cc <= g.c1; cc++) s += num(value(sid, rr, cc, depth + 1)); return `(${s})`; }); f = f.replace(/\$?([A-Z]{1,3})\$?(\d+)/g, (_, col, row) => { const v = value(sid, Number(row) - 1, colIndex(col), depth + 1); return `(${num(v)})`; }); if (!/^[\d+\-*/(). e]*$/.test(f)) return { error: "#NAME?" }; try { return Function(`return (${f})`)(); } catch { return { error: "#NAME?" }; } } return { update: (changed) => { const out = [...changed]; for (const s of book.시트) for (const k of Object.keys(s.칸)) if (s.칸[k].식 !== undefined) out.push({ 시트: s.id, ...parseA1(k) }); return out; }, rebuild: (b) => (book = b), value: (s, r, c) => value(s, r, c), precedents: () => [], snapshot: () => ({}), }; } const graph = { createCalcEngine }; // ── C 격자 (고정 칸 크기 · 병합 · 고름 · 참조 테두리 · 채우기 손잡이) ──────────── const W = 72; const H = 22; const HW = 40; const HH = 22; const ROWS = 60; const COLS = 20; const REF_COLORS = ["#1f6feb", "#cf222e", "#8250df", "#1a7f37", "#bc4c00"]; function createGrid(ctx) { const root = el("div", { className: "fg" }); root.style.cssText = "position:relative;overflow:auto;height:420px;border:1px solid #ccc"; const content = el("div"); content.style.cssText = `position:relative;width:${HW + COLS * W}px;height:${HH + ROWS * H}px;font:12px sans-serif`; root.append(content); const cellLayer = el("div"); const overlay = el("div"); content.append(cellLayer, overlay); let refs = []; const merges = () => (ctx.sheet().병합 || []).map(parseRange).filter(Boolean); const mergeAt = (r, c) => merges().find((m) => r >= m.r0 && r <= m.r1 && c >= m.c0 && c <= m.c1); function cellBox(r, c) { const m = mergeAt(r, c) || { r0: r, c0: c, r1: r, c1: c }; return { x: HW + m.c0 * W, y: HH + m.r0 * H, w: (m.c1 - m.c0 + 1) * W, h: (m.r1 - m.r0 + 1) * H, }; } const rangeBox = (g) => { const a = cellBox(g.r0, g.c0); const b = cellBox(Math.min(g.r1, ROWS - 1), Math.min(g.c1, COLS - 1)); return { x: a.x, y: a.y, w: b.x + b.w - a.x, h: b.y + b.h - a.y }; }; const place = (n, b) => Object.assign(n.style, { position: "absolute", left: `${b.x}px`, top: `${b.y}px`, width: `${b.w}px`, height: `${b.h}px`, boxSizing: "border-box", }); function show(v) { if (v === null || v === undefined) return ""; if (typeof v === "object" && "n" in v) return String(Number(v.n) / Number(v.d)); // 진짜 엔진 분수 if (typeof v === "object") return v.error; if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; return String(v); } function render() { cellLayer.textContent = ""; const sheet = ctx.sheet(); for (let c = 0; c < COLS; c++) { const h = el("div", { text: colName(c) }); place(h, { x: HW + c * W, y: 0, w: W, h: HH }); h.style.cssText += ";background:#f3f3f3;border:1px solid #ddd;text-align:center"; cellLayer.append(h); } for (let r = 0; r < ROWS; r++) { const h = el("div", { text: String(r + 1) }); place(h, { x: 0, y: HH + r * H, w: HW, h: H }); h.style.cssText += ";background:#f3f3f3;border:1px solid #ddd;text-align:center"; cellLayer.append(h); } for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { const m = mergeAt(r, c); if (m && (m.r0 !== r || m.c0 !== c)) continue; const d = el("div", { className: "fg-cell", attrs: { "data-a1": toA1(r, c), "data-addr": toA1(r, c) }, // data-addr = C 격자와 같은 꼴 text: show(ctx.engine.value(sheet.id, r, c)), }); place(d, cellBox(r, c)); d.style.cssText += ";border-right:1px solid #eee;border-bottom:1px solid #eee;padding:2px 3px;overflow:hidden;white-space:nowrap"; const v = ctx.engine.value(sheet.id, r, c); if (typeof v === "number" || (v && typeof v === "object" && "n" in v)) d.style.textAlign = "right"; cellLayer.append(d); } renderSelection(); } function renderSelection() { overlay.textContent = ""; const sel = ctx.selection; for (const g of sel.범위) { const b = el("div", { className: "fg-sel" }); place(b, rangeBox(g)); b.style.cssText += ";border:2px solid #217346;background:rgba(33,115,70,.08);pointer-events:none"; overlay.append(b); } const g = sel.범위[0]; const rb = rangeBox(g); const hd = el("div", { className: "fg-handle" }); place(hd, { x: rb.x + rb.w - 4, y: rb.y + rb.h - 4, w: 7, h: 7 }); hd.style.cssText += ";background:#217346;border:1px solid #fff;pointer-events:none"; overlay.append(hd); refs.forEach((x) => { if (x.시트 !== ctx.selection.시트) return; const b = el("div", { className: "fg-ref", attrs: { "data-ref": rangeToA1(x.범위) } }); place(b, rangeBox(x.범위)); const col = REF_COLORS[x.색번호 % REF_COLORS.length]; b.style.cssText += `;border:2px solid ${col};pointer-events:none`; overlay.append(b); }); } function hitTest(cx, cy) { const rect = content.getBoundingClientRect(); const x = cx - rect.left; const y = cy - rect.top; const outer = root.getBoundingClientRect(); if (cx < outer.left || cx > outer.right || cy < outer.top || cy > outer.bottom) return null; const c = Math.floor((x - HW) / W); const r = Math.floor((y - HH) / H); const rb = rangeBox(ctx.selection.범위[0]); if (Math.abs(x - (rb.x + rb.w)) <= 4 && Math.abs(y - (rb.y + rb.h)) <= 4) return { kind: "fillHandle", r: Math.min(ctx.selection.범위[0].r1, ROWS - 1), c: Math.min(ctx.selection.범위[0].c1, COLS - 1), }; if (x < HW && y < HH) return { kind: "corner", r: -1, c: -1 }; if (y < HH) return { kind: "colHead", r: -1, c: Math.max(0, Math.min(COLS - 1, c)) }; if (x < HW) return { kind: "rowHead", r: Math.max(0, Math.min(ROWS - 1, r)), c: -1 }; return { kind: "cell", r: Math.max(0, Math.min(ROWS - 1, r)), c: Math.max(0, Math.min(COLS - 1, c)), }; } return { root, render, invalidate: () => render(), renderSelection, setRefHighlights(list) { refs = list; renderSelection(); }, cellBox, hitTest, reveal(r, c) { const b = cellBox(Math.min(r, ROWS - 1), Math.min(c, COLS - 1)); if (b.y - HH < root.scrollTop) root.scrollTop = b.y - HH; if (b.y + b.h > root.scrollTop + root.clientHeight) root.scrollTop = b.y + b.h - root.clientHeight; if (b.x - HW < root.scrollLeft) root.scrollLeft = b.x - HW; if (b.x + b.w > root.scrollLeft + root.clientWidth) root.scrollLeft = b.x + b.w - root.clientWidth; }, visibleRange() { const r0 = Math.floor(root.scrollTop / H); return { r0, r1: r0 + Math.floor(root.clientHeight / H) - 2, c0: 0, c1: COLS - 1 }; }, // C 와 같은 꼴 — box 는 격자 뿌리 기준(스크롤 뺌) editorSlot: (r, c) => { const b = cellBox(r, c); return { layer: content, box: { ...b, x: b.x - root.scrollLeft, y: b.y - root.scrollTop } }; }, destroy: () => root.remove(), }; } const grid = { createGrid }; // ── E 부품 (탭만 단추 · 나머지 빈 손잡이) ─────────────────────────────────── const none = () => ({ root: null, refresh() {}, destroy() {} }); function mountTabs(ctx) { const root = el("div", { className: "fg-tabs" }); root.style.cssText = "display:flex;gap:4px;padding:4px"; function refresh() { root.textContent = ""; for (const s of ctx.book.시트) { const b = el("button", { text: s.이름, attrs: { "data-sheet": s.id } }); if (s.id === ctx.selection.시트) b.style.fontWeight = "bold"; b.addEventListener("click", () => ctx.showSheet(s.id)); root.append(b); } } refresh(); return { root, refresh, destroy: () => root.remove() }; } module.exports = { el: { el }, // B 함수 — 없음(진짜 엔진이 부르면 #NAME?) functions: { getFunction: () => undefined, functionNames: () => [] }, address, parser, refshift, fill, commands, history, graph, grid, toolbar: { mountToolbar: none }, tabs: { mountTabs }, menu: { attachMenu: none }, clipboard: { attachClipboard: none, parseClipboard: () => null, blockToClipboard: () => ({ html: "", text: "" }), }, };