- spreadsheet.ts — `inspect` 선택이면 문맥을 window.__aisloSheet 에 걺(M02 [스프레드시트 시험] 단추 전용 · destroy 때 뗌 · 1단계 검증 뒤 지움) - 시험 틀 — ssScenario()(페이지 안 한 번에 한글 · 가리키기 · 재계산 · 되돌리기 · Delete · Ctrl+Enter · 수식 입력줄 · F2) · 가짜 격자 data-addr · 진짜 분수 값 보임 · 오류 모음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
486 lines
18 KiB
JavaScript
486 lines
18 KiB
JavaScript
/* 스프레드시트 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 };
|
|
},
|
|
editorSlot: (r, c) => ({ layer: content, box: cellBox(r, c) }),
|
|
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: "" }),
|
|
},
|
|
};
|