Files
Aislo/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs
T
eomsangdonandClaude Opus 5.5 ce3df39206 feat(spreadsheet): D 잇기 마무리 — 진짜 부품으로 createSpreadsheet · 2단계 찾기 · 메모 · 서식 붓 · 골라 붙여넣기 · 폭 맞춤 · 함수 도움말
- spreadsheet.ts — 가짜 부품 자리(parts) 걷고 A · B · C · E 직접 잇기 · 2단계는 spreadsheet_extras.ts 로
- spreadsheet_extras.ts(새) — 메모 풍선(마우스 올림) · Shift+F2 메모 편집 · 서식 붓 단추 · Ctrl+Shift+V 값만 · 서식만 · 수식만 · 도구 모음 뒤 초점 되돌림
- spreadsheet_find_panel.ts(새) — Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 패널
- spreadsheet_editor.ts — C editorSlot(격자 뿌리 기준) → 층 안 좌표 맞춤(slotOf) · 함수 자동 완성(Tab) · 인자 도움말
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 2단계 잇기 자리 · 열 경계 두 번 누르면 폭 자동 맞춤
- 시험 — 함수 도움말 자리 판정 추가(8 묶음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 20:57:25 +09:00

490 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 };
},
// 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: "" }),
},
};