Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/* spreadsheet.css (주인 D) — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 */
|
||||
|
||||
.ss {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
color: #1f2328;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ss-grid-host {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ss-bar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: stretch;
|
||||
height: 26px;
|
||||
border-bottom: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.ss-addr {
|
||||
width: 96px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-right: 1px solid #d0d7de;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-addr:focus {
|
||||
box-shadow: inset 0 0 0 2px #217346;
|
||||
}
|
||||
|
||||
.ss-fx {
|
||||
align-self: center;
|
||||
padding: 0 8px;
|
||||
color: #6e7781;
|
||||
font-style: italic;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ss-formula {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-left: 1px solid #d0d7de;
|
||||
font: inherit;
|
||||
line-height: 18px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss--readonly .ss-formula {
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
/* 칸 편집기 = 숨은 textarea — 쉴 때는 활성 칸 자리에 투명하게(한글 조합 창 자리) */
|
||||
.ss-input {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 1px 3px;
|
||||
border: 2px solid #217346;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font: inherit;
|
||||
line-height: 1.25;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-input--idle {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-fill-preview {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #57606a;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-toast {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 40px;
|
||||
z-index: 20;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: #24292f;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -6,10 +6,38 @@
|
||||
* 키 · 마우스)을 잇음. 한글 조합 = 숨은 textarea 에 늘 초점 · compositionstart 로 편집 시작(keydown 글자 방식 금지).
|
||||
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
* 0 계약 머리 — 몸은 D 가 채움.
|
||||
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcValues, Workbook } from "./spreadsheet_types";
|
||||
import "./spreadsheet.css";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { attachClipboard } from "./spreadsheet_clipboard";
|
||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||
import { createEditor } from "./spreadsheet_editor";
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
||||
import { mountTabs } from "./spreadsheet_tabs";
|
||||
import { mountToolbar } from "./spreadsheet_toolbar";
|
||||
import type {
|
||||
CalcValues,
|
||||
Command,
|
||||
CommandEffect,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type {
|
||||
EditorHandle,
|
||||
GridHandle,
|
||||
PartHandle,
|
||||
Selection,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
export type { Workbook, CalcValues } from "./spreadsheet_types";
|
||||
|
||||
@@ -32,14 +60,197 @@ export interface SpreadsheetHandle {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet: 아직 없음(D)");
|
||||
/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */
|
||||
export const parts = {
|
||||
createCalcEngine,
|
||||
createHistory,
|
||||
applyCommand,
|
||||
createGrid,
|
||||
mountToolbar,
|
||||
mountTabs,
|
||||
attachMenu,
|
||||
attachClipboard,
|
||||
};
|
||||
|
||||
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
|
||||
function farCorner(sel: Selection): { r: number; c: number } {
|
||||
const g = sel.범위[0];
|
||||
const a = sel.기준;
|
||||
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
|
||||
return {
|
||||
r: whole(g.r0, g.r1, LAST_R)
|
||||
? sel.활성.r
|
||||
: Math.abs(g.r1 - a.r) >= Math.abs(a.r - g.r0)
|
||||
? g.r1
|
||||
: g.r0,
|
||||
c: whole(g.c0, g.c1, LAST_C)
|
||||
? sel.활성.c
|
||||
: Math.abs(g.c1 - a.c) >= Math.abs(a.c - g.c0)
|
||||
? g.c1
|
||||
: g.c0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpreadsheet(
|
||||
_host: HTMLElement,
|
||||
_book: Workbook,
|
||||
_opts: SpreadsheetOptions,
|
||||
host: HTMLElement,
|
||||
book: Workbook,
|
||||
opts: SpreadsheetOptions = {},
|
||||
): SpreadsheetHandle {
|
||||
return todo();
|
||||
const readOnly = !!opts.readOnly;
|
||||
const root = el("div", { className: readOnly ? "ss ss--readonly" : "ss" });
|
||||
host.append(root);
|
||||
const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0];
|
||||
/** 시트마다 마지막 고름(탭을 오가도 그대로) */
|
||||
const memory = new Map<string, Selection>();
|
||||
|
||||
const ctx: SpreadsheetContext = {
|
||||
root,
|
||||
book,
|
||||
readOnly,
|
||||
engine: parts.createCalcEngine(book),
|
||||
history: parts.createHistory(),
|
||||
selection: selectCell(firstSheet(book), 0, 0),
|
||||
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
|
||||
dispatch,
|
||||
undo: () => replay(ctx.history.undo()),
|
||||
redo: () => replay(ctx.history.redo()),
|
||||
select,
|
||||
showSheet,
|
||||
grid: null as unknown as GridHandle,
|
||||
editor: null as unknown as EditorHandle,
|
||||
};
|
||||
|
||||
const grid = parts.createGrid(ctx);
|
||||
ctx.grid = grid;
|
||||
const ed = createEditor(ctx);
|
||||
ctx.editor = ed;
|
||||
const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx);
|
||||
const tabs = parts.mountTabs(ctx);
|
||||
const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx);
|
||||
const clip = parts.attachClipboard(ctx);
|
||||
const others = [toolbar, tabs, menu, clip].filter((p): p is PartHandle => !!p);
|
||||
|
||||
grid.root.classList.add("ss-grid-host");
|
||||
for (const node of [toolbar?.root, ed.bar, grid.root, tabs.root]) if (node) root.append(node);
|
||||
const detachKeys = attachKeys(ctx, ed);
|
||||
const detachMouse = attachMouse(ctx, ed);
|
||||
const toast = el("div", { className: "ss-toast" });
|
||||
let toastTimer = 0;
|
||||
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
root.append(toast);
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function apply(command: Command): CommandEffect | null {
|
||||
try {
|
||||
return parts.applyCommand(ctx.book, command);
|
||||
} catch (e) {
|
||||
if (e instanceof CommandError) return (notify(e.message), null);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatch(command: Command): void {
|
||||
if (readOnly) return;
|
||||
const eff = apply(command);
|
||||
if (!eff) return;
|
||||
ctx.history.push(command, eff.undo);
|
||||
after(command, eff);
|
||||
}
|
||||
|
||||
function replay(command: Command | null): void {
|
||||
if (readOnly || !command || ed.editing()) return;
|
||||
const eff = apply(command);
|
||||
if (eff) after(command, eff);
|
||||
}
|
||||
|
||||
function after(command: Command, eff: CommandEffect): void {
|
||||
if (eff.rebuild) {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
if (!ctx.book.시트.some((s) => s.id === ctx.selection.시트)) {
|
||||
const s = firstSheet(ctx.book);
|
||||
ctx.selection = memory.get(s.id) ?? selectCell(s, 0, 0);
|
||||
}
|
||||
grid.render();
|
||||
} else {
|
||||
const again = ctx.engine.update(eff.cells);
|
||||
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
|
||||
else grid.render();
|
||||
}
|
||||
select(ctx.selection);
|
||||
opts.onChange?.(structuredClone(ctx.book));
|
||||
}
|
||||
|
||||
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
|
||||
function touched(sheet: string, cells: Record<string, unknown>): SheetCellAddress[] {
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const key of Object.keys(cells)) {
|
||||
const a = parseA1(key);
|
||||
if (a) out.push({ 시트: sheet, ...a });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function select(selection: Selection, reveal = false): void {
|
||||
ctx.selection = selection;
|
||||
memory.set(selection.시트, selection);
|
||||
grid.renderSelection();
|
||||
if (reveal) {
|
||||
const far = farCorner(selection);
|
||||
grid.reveal(far.r, far.c);
|
||||
}
|
||||
ed.sync();
|
||||
for (const p of others) p.refresh();
|
||||
const g = selection.범위[0];
|
||||
opts.onSelect?.({
|
||||
시트: selection.시트,
|
||||
칸: toA1(selection.활성.r, selection.활성.c),
|
||||
범위: g ? rangeToA1(g) : toA1(selection.활성.r, selection.활성.c),
|
||||
});
|
||||
}
|
||||
|
||||
function showSheet(sheetId: string): void {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet || sheetId === ctx.selection.시트) return;
|
||||
ed.commit();
|
||||
ctx.book.활성 = sheetId;
|
||||
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection, true);
|
||||
ed.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
getDoc: () => structuredClone(ctx.book),
|
||||
setDoc(next: Workbook) {
|
||||
ed.cancel();
|
||||
ctx.book = next;
|
||||
ctx.engine.rebuild(next);
|
||||
ctx.history.clear();
|
||||
memory.clear();
|
||||
ctx.selection = selectCell(firstSheet(next), 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
},
|
||||
recalc() {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
grid.render();
|
||||
return ctx.engine.snapshot();
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
detachMouse();
|
||||
for (const p of others) p.destroy();
|
||||
ed.destroy();
|
||||
grid.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_editor.ts (주인 D)
|
||||
* 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) ·
|
||||
* 참조 칸 색 테두리.
|
||||
* 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을
|
||||
* 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지).
|
||||
* 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 ·
|
||||
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { moveFormula } from "./spreadsheet_refshift";
|
||||
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { tokenize } from "./spreadsheet_parser";
|
||||
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
|
||||
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellBox,
|
||||
EditorHandle,
|
||||
RefHighlight,
|
||||
Selection,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
export interface Editor extends EditorHandle {
|
||||
/** 숨은 textarea = 칸 편집기(늘 초점) */
|
||||
ta: HTMLTextAreaElement;
|
||||
/** 주소 상자 + 수식 입력줄 한 줄 */
|
||||
bar: HTMLElement;
|
||||
formula: HTMLTextAreaElement;
|
||||
mode(): "enter" | "edit";
|
||||
toggleMode(): void;
|
||||
/** 지금 글자 자리에 참조를 넣을 수 있나(식 · 연산자 뒤) */
|
||||
pointable(): boolean;
|
||||
/** 가리킨 고름을 식 글에 넣음(같은 가리키기면 바꿔 적음) */
|
||||
point(sel: Selection): void;
|
||||
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
|
||||
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
|
||||
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
|
||||
commitAll(): void;
|
||||
/** 초점 되찾기(스크롤 없이) */
|
||||
focus(): void;
|
||||
/** 편집 중이면 칸 자리 다시(스크롤 · 폭 바뀜) */
|
||||
place(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
sheet: string;
|
||||
at: CellAddress;
|
||||
mode: "enter" | "edit";
|
||||
original: string;
|
||||
/** 가리키기 중 — 식 글 [start, end) 가 가리킨 참조 */
|
||||
point: { start: number; end: number; sel: Selection } | null;
|
||||
}
|
||||
|
||||
/** 수 입력 — 1,234 · -1.5e3 · 50% (엑셀 입력 규칙) · 아니면 null */
|
||||
export function parseNumber(text: string): number | null {
|
||||
let t = text.trim();
|
||||
const pct = t.endsWith("%");
|
||||
if (pct) t = t.slice(0, -1);
|
||||
if (!/\d/.test(t) || !/^[+-]?(\d{1,3}(,\d{3})+|\d*)(\.\d*)?([eE][+-]?\d+)?$/.test(t)) return null;
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return pct ? n / 100 : n;
|
||||
}
|
||||
|
||||
const looksTyped = (s: string): boolean =>
|
||||
(s.length > 1 && s[0] === "=") ||
|
||||
s[0] === "'" ||
|
||||
parseNumber(s) !== null ||
|
||||
/^(true|false)$/i.test(s);
|
||||
|
||||
/** 친 글 → 칸(서식은 두고) · 지울 칸이면 null */
|
||||
export function textToCell(text: string, old?: Cell): Cell | null {
|
||||
const base: Cell = old?.서식 !== undefined ? { 서식: old.서식 } : {};
|
||||
if (text === "") return old?.서식 !== undefined ? base : null;
|
||||
if (text.length > 1 && text[0] === "=") return { ...base, 식: text.slice(1) };
|
||||
if (text[0] === "'") return { ...base, 값: text.slice(1) };
|
||||
const n = parseNumber(text);
|
||||
if (n !== null) return { ...base, 값: n };
|
||||
if (/^(true|false)$/i.test(text)) return { ...base, 값: text.toUpperCase() === "TRUE" };
|
||||
return { ...base, 값: text };
|
||||
}
|
||||
|
||||
/** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */
|
||||
export function cellToText(cell: Cell | undefined): string {
|
||||
if (!cell) return "";
|
||||
if (cell.식 !== undefined) return "=" + cell.식;
|
||||
const v = cell.값;
|
||||
if (v === undefined) return "";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number") return String(v);
|
||||
return looksTyped(v) ? "'" + v : v;
|
||||
}
|
||||
|
||||
/** 참조 넣을 자리 — 식이고 caret 앞(빈칸 건너)이 연산자 · 괄호 · 쉼표 · `=` */
|
||||
export function insertableAt(text: string, pos: number): boolean {
|
||||
if (text[0] !== "=") return false;
|
||||
if (pos < text.length && /[A-Za-z0-9$_.!']/.test(text[pos])) return false;
|
||||
let i = pos - 1;
|
||||
while (i >= 0 && text[i] === " ") i--;
|
||||
return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]);
|
||||
}
|
||||
|
||||
/** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */
|
||||
export function refHighlights(
|
||||
text: string,
|
||||
host: string,
|
||||
sheetId: (name: string) => string | null,
|
||||
): RefHighlight[] {
|
||||
if (text[0] !== "=") return [];
|
||||
const out: RefHighlight[] = [];
|
||||
const colors = new Map<string, number>();
|
||||
for (const t of tokenize(text.slice(1))) {
|
||||
if (t.kind !== "ref") continue;
|
||||
const bang = t.text.lastIndexOf("!");
|
||||
let sheet: string | null = host;
|
||||
if (bang >= 0) {
|
||||
const raw = t.text.slice(0, bang);
|
||||
const name = raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw;
|
||||
sheet = sheetId(name);
|
||||
}
|
||||
const range = parseRange(t.text.slice(bang + 1).replace(/\$/g, ""));
|
||||
if (!sheet || !range) continue;
|
||||
const key = t.text.toUpperCase();
|
||||
if (!colors.has(key)) colors.set(key, colors.size);
|
||||
out.push({ 시트: sheet, 범위: range, 색번호: colors.get(key)! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 칸 서식(칸 → 행 → 열 → 기본) */
|
||||
function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const idx =
|
||||
sheet.칸[toA1(r, c)]?.서식 ??
|
||||
sheet.행?.[String(r + 1)]?.서식 ??
|
||||
sheet.열?.[colName(c)]?.서식 ??
|
||||
0;
|
||||
return ctx.book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** editorSlot 칸 자리 → 층 안 자리 */
|
||||
export function boxStyle(node: HTMLElement, box: CellBox): void {
|
||||
node.style.left = `${box.x}px`;
|
||||
node.style.top = `${box.y}px`;
|
||||
node.style.minWidth = `${box.w}px`;
|
||||
node.style.minHeight = `${box.h}px`;
|
||||
}
|
||||
|
||||
export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
const ta = el("textarea", {
|
||||
className: "ss-input ss-input--idle",
|
||||
attrs: { spellcheck: "false", autocomplete: "off", rows: "1", "aria-label": "칸 입력" },
|
||||
});
|
||||
const addr = el("input", {
|
||||
className: "ss-addr",
|
||||
attrs: { spellcheck: "false", "aria-label": "주소 상자" },
|
||||
});
|
||||
const formula = el("textarea", {
|
||||
className: "ss-formula",
|
||||
attrs: { spellcheck: "false", rows: "1", "aria-label": "수식 입력줄" },
|
||||
});
|
||||
const bar = el("div", {
|
||||
className: "ss-bar",
|
||||
children: [addr, el("span", { className: "ss-fx", text: "fx" }), formula],
|
||||
});
|
||||
formula.readOnly = ctx.readOnly;
|
||||
ta.readOnly = ctx.readOnly;
|
||||
|
||||
let st: EditState | null = null;
|
||||
|
||||
const src = (): HTMLTextAreaElement => (document.activeElement === formula ? formula : ta);
|
||||
const text = (): string => src().value;
|
||||
const sheetOf = (id: string): Sheet => ctx.book.시트.find((s) => s.id === id) ?? ctx.sheet();
|
||||
const sheetId = (name: string): string | null =>
|
||||
ctx.book.시트.find((s) => s.이름.toUpperCase() === name.toUpperCase())?.id ?? null;
|
||||
|
||||
function place(): void {
|
||||
const at = st?.at ?? ctx.selection.활성;
|
||||
const { layer, box } = ctx.grid.editorSlot(at.r, at.c);
|
||||
if (ta.parentElement !== layer) {
|
||||
const had = document.activeElement === ta;
|
||||
layer.append(ta);
|
||||
if (had) focus();
|
||||
}
|
||||
boxStyle(ta, box);
|
||||
if (!st) return;
|
||||
const s = styleAt(ctx, sheetOf(st.sheet), at.r, at.c);
|
||||
ta.style.fontFamily = s.글꼴 ?? "";
|
||||
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
|
||||
ta.style.fontWeight = s.굵게 ? "bold" : "";
|
||||
ta.style.fontStyle = s.기울임 ? "italic" : "";
|
||||
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
|
||||
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
|
||||
ta.style.width = "0";
|
||||
ta.style.height = "0";
|
||||
ta.style.width = `${Math.max(box.w, ta.scrollWidth + 4)}px`;
|
||||
ta.style.height = `${Math.max(box.h, ta.scrollHeight)}px`;
|
||||
}
|
||||
|
||||
function focus(): void {
|
||||
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function highlight(): void {
|
||||
const t = st ? text() : "";
|
||||
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
|
||||
}
|
||||
|
||||
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
|
||||
function start(mode: "enter" | "edit", value?: string, from: HTMLTextAreaElement = ta): void {
|
||||
if (ctx.readOnly || st) return;
|
||||
const sel = ctx.selection;
|
||||
const sheet = ctx.sheet();
|
||||
const original = cellToText(sheet.칸[toA1(sel.활성.r, sel.활성.c)]);
|
||||
st = { sheet: sheet.id, at: { ...sel.활성 }, mode, original, point: null };
|
||||
if (value !== undefined) from.value = value;
|
||||
(from === ta ? formula : ta).value = from.value;
|
||||
ta.classList.remove("ss-input--idle");
|
||||
ctx.root.classList.add("ss--editing");
|
||||
place();
|
||||
highlight();
|
||||
if (from === ta && value !== undefined) ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
}
|
||||
|
||||
function end(): void {
|
||||
st = null;
|
||||
ta.value = "";
|
||||
ta.classList.add("ss-input--idle");
|
||||
ta.removeAttribute("style");
|
||||
ctx.root.classList.remove("ss--editing");
|
||||
ctx.grid.setRefHighlights([]);
|
||||
sync();
|
||||
focus();
|
||||
}
|
||||
|
||||
function write(cells: CellAddress[], value: string, from: CellAddress): void {
|
||||
if (!st) return;
|
||||
const sheet = sheetOf(st.sheet);
|
||||
const out: Record<string, Cell | null> = {};
|
||||
for (const a of cells) {
|
||||
let v = value;
|
||||
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
|
||||
v = "=" + moveFormula(v.slice(1), a.r - from.r, a.c - from.c);
|
||||
const key = toA1(a.r, a.c);
|
||||
const next = textToCell(v, sheet.칸[key]);
|
||||
if (next === null && !sheet.칸[key]) continue;
|
||||
out[key] = next;
|
||||
}
|
||||
if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out });
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
if (!st) return;
|
||||
const value = text();
|
||||
if (value !== st.original) write([st.at], value, st.at);
|
||||
end();
|
||||
}
|
||||
|
||||
function commitAll(): void {
|
||||
if (!st) return;
|
||||
write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at);
|
||||
end();
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
if (!st) return;
|
||||
end();
|
||||
}
|
||||
|
||||
function sync(): void {
|
||||
const sel = ctx.selection;
|
||||
addr.value = toA1(sel.활성.r, sel.활성.c);
|
||||
if (st) return;
|
||||
formula.value = cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]);
|
||||
place();
|
||||
}
|
||||
|
||||
function pointable(): boolean {
|
||||
if (!st) return false;
|
||||
const node = src();
|
||||
if (node.selectionStart !== node.selectionEnd) return false;
|
||||
const pos = node.selectionEnd;
|
||||
if (st.point && st.point.end === pos) return true;
|
||||
st.point = null;
|
||||
return insertableAt(node.value, pos);
|
||||
}
|
||||
|
||||
function point(sel: Selection): void {
|
||||
if (!st) return;
|
||||
const node = src();
|
||||
const other = node === ta ? formula : ta;
|
||||
const pos = node.selectionEnd;
|
||||
const p = st.point ?? { start: pos, end: pos, sel };
|
||||
const g = sel.범위[0];
|
||||
const ref = rangeToA1(g);
|
||||
node.value = node.value.slice(0, p.start) + ref + node.value.slice(p.end);
|
||||
other.value = node.value;
|
||||
st.point = { start: p.start, end: p.start + ref.length, sel };
|
||||
node.setSelectionRange(st.point.end, st.point.end);
|
||||
place();
|
||||
highlight();
|
||||
ctx.grid.reveal(sel.활성.r === g.r0 ? g.r1 : g.r0, sel.활성.c === g.c0 ? g.c1 : g.c0);
|
||||
}
|
||||
|
||||
function pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void {
|
||||
if (!st) return;
|
||||
const sheet = sheetOf(st.sheet);
|
||||
const from = st.point?.sel ?? selectCell(sheet, st.at.r, st.at.c);
|
||||
point(move(sheet, from, dr, dc, { extend, jump }));
|
||||
}
|
||||
|
||||
// ── 한글 조합 · 바로 치기 → 편집 시작 ─────────────────────────────────────
|
||||
ta.addEventListener("compositionstart", () => {
|
||||
if (!st) start("enter");
|
||||
});
|
||||
ta.addEventListener("input", () => {
|
||||
if (!st) return start("enter");
|
||||
formula.value = ta.value;
|
||||
if (st.point && ta.selectionEnd !== st.point.end) st.point = null;
|
||||
place();
|
||||
highlight();
|
||||
});
|
||||
// 수식 입력줄 — 누르면 edit 방식으로 시작
|
||||
formula.addEventListener("focus", () => {
|
||||
if (!st && !ctx.readOnly) start("edit", formula.value, formula);
|
||||
});
|
||||
formula.addEventListener("input", () => {
|
||||
if (!st) return start("edit", undefined, formula);
|
||||
ta.value = formula.value;
|
||||
if (st.point && formula.selectionEnd !== st.point.end) st.point = null;
|
||||
place();
|
||||
highlight();
|
||||
});
|
||||
// 주소 상자 — `B5` · `A1:C3` · `시트!B5` 로 가기
|
||||
addr.addEventListener("focus", () => addr.select());
|
||||
addr.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
sync();
|
||||
focus();
|
||||
}
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
goTo(addr.value.trim());
|
||||
focus();
|
||||
});
|
||||
|
||||
function goTo(value: string): void {
|
||||
const bang = value.lastIndexOf("!");
|
||||
let sheet = ctx.sheet();
|
||||
if (bang >= 0) {
|
||||
const raw = value.slice(0, bang);
|
||||
const id = sheetId(raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw);
|
||||
if (!id) return sync();
|
||||
if (id !== sheet.id) ctx.showSheet(id);
|
||||
sheet = ctx.sheet();
|
||||
}
|
||||
const body = value.slice(bang + 1).replace(/\$/g, "");
|
||||
const one = parseA1(body);
|
||||
const g = parseRange(body);
|
||||
if (!g) return sync();
|
||||
if (one) return ctx.select(selectCell(sheet, one.r, one.c), true);
|
||||
const at = { r: g.r0, c: g.c0 };
|
||||
ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true);
|
||||
}
|
||||
|
||||
return {
|
||||
ta,
|
||||
bar,
|
||||
formula,
|
||||
editing: () => st !== null,
|
||||
begin(initial?: string) {
|
||||
if (initial === undefined) {
|
||||
const sel = ctx.selection;
|
||||
start("edit", cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]));
|
||||
} else start("enter", initial);
|
||||
focus();
|
||||
},
|
||||
commit,
|
||||
commitAll,
|
||||
cancel,
|
||||
sync,
|
||||
mode: () => st?.mode ?? "enter",
|
||||
toggleMode() {
|
||||
if (st) ((st.mode = st.mode === "enter" ? "edit" : "enter"), (st.point = null));
|
||||
},
|
||||
pointable,
|
||||
point,
|
||||
pointMove,
|
||||
focus,
|
||||
place() {
|
||||
if (st) place();
|
||||
},
|
||||
destroy() {
|
||||
ta.remove();
|
||||
bar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_keys.ts (주인 D)
|
||||
* 엑셀 단축키 지도 — 숨은 textarea · 수식 입력줄 keydown 하나로 받음.
|
||||
* 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 ·
|
||||
* enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기).
|
||||
* 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) ·
|
||||
* F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R.
|
||||
* 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자).
|
||||
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
cycle,
|
||||
LAST_R,
|
||||
move,
|
||||
selectAll,
|
||||
selectCell,
|
||||
selectCols,
|
||||
selectRows,
|
||||
selectSpan,
|
||||
usedEnd,
|
||||
} from "./spreadsheet_selection";
|
||||
import type { Editor } from "./spreadsheet_editor";
|
||||
import type { Selection, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const ARROWS: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
|
||||
export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
const go = (sel: Selection): void => ctx.select(sel, true);
|
||||
|
||||
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
|
||||
function next(dr: number, dc: number): void {
|
||||
const sheet = ctx.sheet();
|
||||
go(cycle(sheet, ctx.selection, dr, dc) ?? move(sheet, ctx.selection, dr, dc));
|
||||
}
|
||||
|
||||
function editingKey(e: KeyboardEvent, inBar: boolean): boolean {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
switch (e.key) {
|
||||
case "Enter": {
|
||||
const node = e.target as HTMLTextAreaElement;
|
||||
if (e.altKey) {
|
||||
node.setRangeText("\n", node.selectionStart, node.selectionEnd, "end");
|
||||
node.dispatchEvent(new Event("input"));
|
||||
return true;
|
||||
}
|
||||
if (ctrl) return (ed.commitAll(), true);
|
||||
ed.commit();
|
||||
next(e.shiftKey ? -1 : 1, 0);
|
||||
return true;
|
||||
}
|
||||
case "Tab":
|
||||
ed.commit();
|
||||
next(0, e.shiftKey ? -1 : 1);
|
||||
return true;
|
||||
case "Escape":
|
||||
ed.cancel();
|
||||
return true;
|
||||
case "F2":
|
||||
ed.toggleMode();
|
||||
return true;
|
||||
}
|
||||
const arrow = ARROWS[e.key];
|
||||
if (!arrow || inBar || ed.mode() === "edit") return false;
|
||||
if (ed.pointable()) {
|
||||
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
|
||||
return true;
|
||||
}
|
||||
if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김
|
||||
ed.commit();
|
||||
go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
function navKey(e: KeyboardEvent): boolean {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const sheet = ctx.sheet();
|
||||
const sel = ctx.selection;
|
||||
const arrow = ARROWS[e.key];
|
||||
if (arrow && !e.altKey) {
|
||||
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
|
||||
return true;
|
||||
}
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (ctrl && !e.altKey) {
|
||||
switch (k) {
|
||||
case "z":
|
||||
e.shiftKey ? ctx.redo() : ctx.undo();
|
||||
return true;
|
||||
case "y":
|
||||
ctx.redo();
|
||||
return true;
|
||||
case "a":
|
||||
go(selectAll(sheet, sel.활성));
|
||||
return true;
|
||||
case "Home":
|
||||
go(e.shiftKey ? selectSpan(sheet, sel.기준, { r: 0, c: 0 }) : selectCell(sheet, 0, 0));
|
||||
return true;
|
||||
case "End": {
|
||||
const end = usedEnd(sheet);
|
||||
go(e.shiftKey ? selectSpan(sheet, sel.기준, end) : selectCell(sheet, end.r, end.c));
|
||||
return true;
|
||||
}
|
||||
case " ": {
|
||||
const g = sel.범위[0];
|
||||
go(selectCols(sheet, g.c0, g.c1, sel.활성.c));
|
||||
return true;
|
||||
}
|
||||
case "d":
|
||||
case "r":
|
||||
fill(k === "d");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
next(e.shiftKey ? -1 : 1, 0);
|
||||
return true;
|
||||
case "Tab":
|
||||
next(0, e.shiftKey ? -1 : 1);
|
||||
return true;
|
||||
case "F2":
|
||||
ed.begin();
|
||||
return true;
|
||||
case "Delete":
|
||||
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
|
||||
return true;
|
||||
case "Backspace":
|
||||
ed.begin("");
|
||||
return true;
|
||||
case "Home":
|
||||
go(
|
||||
e.shiftKey
|
||||
? selectSpan(sheet, sel.기준, { r: sel.기준.r, c: 0 })
|
||||
: selectCell(sheet, sel.활성.r, 0),
|
||||
);
|
||||
return true;
|
||||
case "PageDown":
|
||||
case "PageUp": {
|
||||
const v = ctx.grid.visibleRange();
|
||||
const n = Math.max(1, v.r1 - v.r0) * (e.key === "PageDown" ? 1 : -1);
|
||||
const r = Math.min(LAST_R, Math.max(0, sel.활성.r + n));
|
||||
go(selectCell(sheet, r, sel.활성.c));
|
||||
return true;
|
||||
}
|
||||
case " ":
|
||||
if (!e.shiftKey) return false;
|
||||
go(selectRows(sheet, sel.범위[0].r0, sel.범위[0].r1, sel.활성.r));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Ctrl+D 아래로 · Ctrl+R 오른쪽으로 — 고름 첫 행(열)을 나머지에 */
|
||||
function fill(down: boolean): void {
|
||||
if (ctx.readOnly) return;
|
||||
const sheet = ctx.sheet();
|
||||
const g = ctx.selection.범위[0];
|
||||
const src = down ? { ...g, r1: g.r0 } : { ...g, c1: g.c0 };
|
||||
if ((down ? g.r1 : g.c1) === (down ? src.r1 : src.c1)) return;
|
||||
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: fillCells(ctx.book, sheet.id, src, g) });
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
|
||||
const inBar = e.target === ed.formula;
|
||||
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
|
||||
if (done) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
ed.ta.addEventListener("keydown", onKey);
|
||||
ed.formula.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
ed.ta.removeEventListener("keydown", onKey);
|
||||
ed.formula.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_mouse.ts (주인 D)
|
||||
* 마우스 — 칸 · 범위 끌기 · Shift 늘림 · 행열 머리 · 모서리(전체) · 식 편집 중 참조 가리키기 ·
|
||||
* 채우기 핸들 끌기(늘림 · 안으로 끌면 지움) · 두 번 누르기(칸 편집 · 핸들이면 옆 열 끝까지 아래로) ·
|
||||
* 우클릭 자리(고름 밖이면 그 칸을 고름 — 메뉴는 E).
|
||||
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
hasValue,
|
||||
inRange,
|
||||
LAST_C,
|
||||
LAST_R,
|
||||
selectAll,
|
||||
selectCell,
|
||||
selectCols,
|
||||
selectRows,
|
||||
selectSpan,
|
||||
} from "./spreadsheet_selection";
|
||||
import { boxStyle, type Editor } from "./spreadsheet_editor";
|
||||
import type { CellAddress, CellRange } from "./spreadsheet_types";
|
||||
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
/** 채우기 끌기 목표 — 늘림(fill) · 안으로 줄임(clear = 지울 부분) */
|
||||
export function fillTarget(
|
||||
src: CellRange,
|
||||
p: CellAddress,
|
||||
): { target: CellRange; clear: CellRange | null } {
|
||||
if (inRange(src, p.r, p.c)) {
|
||||
const up = src.r1 - p.r;
|
||||
const left = src.c1 - p.c;
|
||||
if (up === 0 && left === 0) return { target: src, clear: null };
|
||||
if (up >= left) return { target: { ...src, r1: p.r }, clear: { ...src, r0: p.r + 1 } };
|
||||
return { target: { ...src, c1: p.c }, clear: { ...src, c0: p.c + 1 } };
|
||||
}
|
||||
const down = p.r - src.r1;
|
||||
const upOut = src.r0 - p.r;
|
||||
const right = p.c - src.c1;
|
||||
const leftOut = src.c0 - p.c;
|
||||
const best = Math.max(down, upOut, right, leftOut);
|
||||
if (best === down) return { target: { ...src, r1: p.r }, clear: null };
|
||||
if (best === upOut) return { target: { ...src, r0: p.r }, clear: null };
|
||||
if (best === right) return { target: { ...src, c1: p.c }, clear: null };
|
||||
return { target: { ...src, c0: p.c }, clear: null };
|
||||
}
|
||||
|
||||
export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
const root = ctx.grid.root;
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "ss-fill-preview";
|
||||
let stopDrag: (() => void) | null = null;
|
||||
|
||||
/** 끌기 — 누른 채 움직이면 `onMove(칸)` · 떼면 `onUp` · 격자 밖이면 가장자리 칸(스크롤 따라감) */
|
||||
function drag(onMove: (p: HitResult) => void, onUp?: () => void): void {
|
||||
const move = (e: MouseEvent): void => {
|
||||
const box = root.getBoundingClientRect();
|
||||
const x = Math.min(box.right - 2, Math.max(box.left + 2, e.clientX));
|
||||
const y = Math.min(box.bottom - 2, Math.max(box.top + 2, e.clientY));
|
||||
const hit = ctx.grid.hitTest(x, y);
|
||||
if (!hit || hit.r < 0 || hit.c < 0) return;
|
||||
onMove(hit);
|
||||
if (x !== e.clientX || y !== e.clientY) ctx.grid.reveal(hit.r, hit.c);
|
||||
};
|
||||
const up = (): void => {
|
||||
stopDrag?.();
|
||||
onUp?.();
|
||||
};
|
||||
stopDrag = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
stopDrag = null;
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
}
|
||||
|
||||
const inSelection = (r: number, c: number): boolean =>
|
||||
ctx.selection.범위.some((g) => inRange(g, r, c));
|
||||
|
||||
function showPreview(g: CellRange | null): void {
|
||||
if (!g) return preview.remove();
|
||||
const a = ctx.grid.editorSlot(g.r0, g.c0);
|
||||
const b = ctx.grid.editorSlot(Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
|
||||
if (preview.parentElement !== a.layer) a.layer.append(preview);
|
||||
boxStyle(preview, {
|
||||
x: a.box.x,
|
||||
y: a.box.y,
|
||||
w: b.box.x + b.box.w - a.box.x,
|
||||
h: b.box.y + b.box.h - a.box.y,
|
||||
});
|
||||
}
|
||||
|
||||
function fillDrag(): void {
|
||||
const sheet = ctx.sheet();
|
||||
const src = ctx.selection.범위[0];
|
||||
let result = { target: src, clear: null as CellRange | null };
|
||||
drag(
|
||||
(p) => {
|
||||
result = fillTarget(src, p);
|
||||
showPreview(result.clear ?? result.target);
|
||||
},
|
||||
() => {
|
||||
showPreview(null);
|
||||
applyFill(sheet.id, src, result.target, result.clear);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function applyFill(
|
||||
sheet: string,
|
||||
src: CellRange,
|
||||
target: CellRange,
|
||||
clear: CellRange | null,
|
||||
): void {
|
||||
const at = ctx.selection.활성;
|
||||
const sel = (g: CellRange): Selection => ({
|
||||
시트: sheet,
|
||||
범위: [g],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
});
|
||||
if (clear) {
|
||||
ctx.dispatch({ 종류: "내용지움", 시트: sheet, 범위: [clear] });
|
||||
ctx.select(sel(target));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.r0 === src.r0 &&
|
||||
target.r1 === src.r1 &&
|
||||
target.c0 === src.c0 &&
|
||||
target.c1 === src.c1
|
||||
)
|
||||
return;
|
||||
ctx.dispatch({ 종류: "칸", 시트: sheet, 칸: fillCells(ctx.book, sheet, src, target) });
|
||||
ctx.select(sel(target));
|
||||
}
|
||||
|
||||
/** 핸들 두 번 누르기 — 왼쪽(없으면 오른쪽) 열 값이 이어진 끝까지 아래로 */
|
||||
function fillDown(): void {
|
||||
const sheet = ctx.sheet();
|
||||
const src = ctx.selection.범위[0];
|
||||
const side = [src.c0 - 1, src.c1 + 1].find(
|
||||
(c) => c >= 0 && c <= LAST_C && hasValue(sheet, src.r1 + 1, c),
|
||||
);
|
||||
if (side === undefined) return;
|
||||
let r = src.r1 + 1;
|
||||
while (r < LAST_R && hasValue(sheet, r + 1, side)) r++;
|
||||
applyFill(sheet.id, src, { ...src, r1: r }, null);
|
||||
}
|
||||
|
||||
function onDown(e: MouseEvent): void {
|
||||
if (e.target === ed.ta) return; // 편집 칸 안 — 글자 자리 옮김
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit || hit.kind === "colEdge" || hit.kind === "rowEdge") return;
|
||||
const sheet = ctx.sheet();
|
||||
if (e.button === 2) {
|
||||
if (ed.editing() || (hit.kind === "cell" && inSelection(hit.r, hit.c))) return;
|
||||
if (hit.kind === "cell") ctx.select(selectCell(sheet, hit.r, hit.c));
|
||||
else if (hit.kind === "rowHead" && !inSelection(hit.r, ctx.selection.활성.c))
|
||||
ctx.select(selectRows(sheet, hit.r, hit.r));
|
||||
else if (hit.kind === "colHead" && !inSelection(ctx.selection.활성.r, hit.c))
|
||||
ctx.select(selectCols(sheet, hit.c, hit.c));
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); // 초점은 편집기 · 수식 입력줄에 둠
|
||||
if (ed.editing()) {
|
||||
if (hit.kind === "cell" && ed.pointable()) {
|
||||
const at = { r: hit.r, c: hit.c };
|
||||
ed.point(selectCell(sheet, hit.r, hit.c));
|
||||
drag((p) => ed.point(selectSpan(sheet, at, { r: p.r, c: p.c })));
|
||||
return;
|
||||
}
|
||||
ed.commit();
|
||||
}
|
||||
ed.focus();
|
||||
if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell";
|
||||
switch (hit.kind) {
|
||||
case "fillHandle":
|
||||
return fillDrag();
|
||||
case "corner":
|
||||
return ctx.select(selectAll(sheet));
|
||||
case "rowHead": {
|
||||
const a = e.shiftKey ? ctx.selection.기준.r : hit.r;
|
||||
ctx.select(selectRows(sheet, a, hit.r, a));
|
||||
return drag((p) => ctx.select(selectRows(sheet, a, p.r, a)));
|
||||
}
|
||||
case "colHead": {
|
||||
const a = e.shiftKey ? ctx.selection.기준.c : hit.c;
|
||||
ctx.select(selectCols(sheet, a, hit.c, a));
|
||||
return drag((p) => ctx.select(selectCols(sheet, a, p.c, a)));
|
||||
}
|
||||
case "cell": {
|
||||
const first = e.shiftKey
|
||||
? selectSpan(sheet, ctx.selection.기준, hit)
|
||||
: selectCell(sheet, hit.r, hit.c);
|
||||
ctx.select(first);
|
||||
const anchor = first.기준;
|
||||
let last = `${hit.r},${hit.c}`;
|
||||
return drag((p) => {
|
||||
if (`${p.r},${p.c}` === last) return;
|
||||
last = `${p.r},${p.c}`;
|
||||
ctx.select(selectSpan(sheet, anchor, p));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDbl(e: MouseEvent): void {
|
||||
if (e.target === ed.ta || ctx.readOnly) return;
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit) return;
|
||||
if (hit.kind === "fillHandle") return fillDown();
|
||||
if (hit.kind === "cell" && !ed.editing()) ed.begin();
|
||||
}
|
||||
|
||||
root.addEventListener("mousedown", onDown);
|
||||
root.addEventListener("dblclick", onDbl);
|
||||
return () => {
|
||||
stopDrag?.();
|
||||
preview.remove();
|
||||
root.removeEventListener("mousedown", onDown);
|
||||
root.removeEventListener("dblclick", onDbl);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_selection.ts (주인 D)
|
||||
* 고름 모델 — 한 칸 · 범위 · 행열 전체 · 전체 · 방향키(Shift 늘림 · Ctrl 끝으로) · 병합으로 늘림 ·
|
||||
* 고름 안 Enter/Tab 돌기. DOM 모름(시험이 그대로 부름). 엑셀 동작이 기준.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type { CellAddress, CellRange, Sheet } from "./spreadsheet_types";
|
||||
import type { Selection } from "./spreadsheet_view_types";
|
||||
|
||||
export const LAST_R = MAX_ROWS - 1;
|
||||
export const LAST_C = MAX_COLS - 1;
|
||||
|
||||
export function merges(sheet: Sheet): CellRange[] {
|
||||
const out: CellRange[] = [];
|
||||
for (const t of sheet.병합 ?? []) {
|
||||
const m = parseRange(t);
|
||||
if (m) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const overlaps = (a: CellRange, b: CellRange): boolean =>
|
||||
a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
|
||||
|
||||
export const inRange = (g: CellRange, r: number, c: number): boolean =>
|
||||
r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1;
|
||||
|
||||
/** 병합 일부를 걸친 범위 → 병합 전체를 품게 늘림(늘린 뒤 새로 걸친 것도) */
|
||||
export function expand(range: CellRange, list: CellRange[]): CellRange {
|
||||
const g = { ...range };
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
for (const m of list) {
|
||||
if (!overlaps(g, m)) continue;
|
||||
if (m.r0 < g.r0 || m.r1 > g.r1 || m.c0 < g.c0 || m.c1 > g.c1) {
|
||||
g.r0 = Math.min(g.r0, m.r0);
|
||||
g.r1 = Math.max(g.r1, m.r1);
|
||||
g.c0 = Math.min(g.c0, m.c0);
|
||||
g.c1 = Math.max(g.c1, m.c1);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** 칸이 든 병합(없으면 칸 하나) */
|
||||
export function mergeAt(sheet: Sheet, r: number, c: number): CellRange {
|
||||
return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c };
|
||||
}
|
||||
|
||||
export const rowHidden = (sheet: Sheet, r: number): boolean => !!sheet.행?.[String(r + 1)]?.숨김;
|
||||
|
||||
export function colHidden(sheet: Sheet, c: number): boolean {
|
||||
const cols = sheet.열;
|
||||
if (!cols) return false;
|
||||
return !!cols[colName(c)]?.숨김;
|
||||
}
|
||||
|
||||
export function hasValue(sheet: Sheet, r: number, c: number): boolean {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
return !!cell && (cell.식 !== undefined || (cell.값 !== undefined && cell.값 !== ""));
|
||||
}
|
||||
|
||||
export function span(a: CellAddress, b: CellAddress): CellRange {
|
||||
return {
|
||||
r0: Math.min(a.r, b.r),
|
||||
c0: Math.min(a.c, b.c),
|
||||
r1: Math.max(a.r, b.r),
|
||||
c1: Math.max(a.c, b.c),
|
||||
};
|
||||
}
|
||||
|
||||
/** 칸 하나 고름 — 병합 안이면 병합 전체 · 활성은 병합 왼위 */
|
||||
export function selectCell(sheet: Sheet, r: number, c: number): Selection {
|
||||
const m = mergeAt(sheet, r, c);
|
||||
const at = { r: m.r0, c: m.c0 };
|
||||
return { 시트: sheet.id, 범위: [m], 활성: at, 기준: { ...at } };
|
||||
}
|
||||
|
||||
/** 닻(기준)에서 `to` 까지 — 활성은 닻 그대로(엑셀 Shift) */
|
||||
export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): Selection {
|
||||
const g = expand(span(anchor, to), merges(sheet));
|
||||
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
|
||||
}
|
||||
|
||||
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
|
||||
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||
const r0 = Math.min(a, b);
|
||||
const at = { r: active ?? r0, c: firstVisible(sheet, "c") };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand({ r0, r1: Math.max(a, b), c0: 0, c1: LAST_C }, merges(sheet))],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
export function selectCols(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||
const c0 = Math.min(a, b);
|
||||
const at = { r: firstVisible(sheet, "r"), c: active ?? c0 };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand({ r0: 0, r1: LAST_R, c0, c1: Math.max(a, b) }, merges(sheet))],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
export function selectAll(sheet: Sheet, active?: CellAddress): Selection {
|
||||
const at = active ?? { r: firstVisible(sheet, "r"), c: firstVisible(sheet, "c") };
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [{ r0: 0, c0: 0, r1: LAST_R, c1: LAST_C }],
|
||||
활성: at,
|
||||
기준: { ...at },
|
||||
};
|
||||
}
|
||||
|
||||
function firstVisible(sheet: Sheet, axis: "r" | "c"): number {
|
||||
let i = 0;
|
||||
while (i < 200 && (axis === "r" ? rowHidden(sheet, i) : colHidden(sheet, i))) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** 한 칸 옮김 — 숨김 건너뜀 · 끝에서 멈춤 */
|
||||
function step(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
|
||||
let { r, c } = at;
|
||||
do {
|
||||
r += dr;
|
||||
c += dc;
|
||||
} while (
|
||||
r >= 0 &&
|
||||
r <= LAST_R &&
|
||||
c >= 0 &&
|
||||
c <= LAST_C &&
|
||||
((dr && rowHidden(sheet, r)) || (dc && colHidden(sheet, c)))
|
||||
);
|
||||
if (r < 0 || r > LAST_R || c < 0 || c > LAST_C) return at;
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
/** 한 줄(행 또는 열)에서 값 든 자리 — 오름차순 */
|
||||
function occupied(sheet: Sheet, at: CellAddress, vertical: boolean): number[] {
|
||||
const out: number[] = [];
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const a = parseA1(key);
|
||||
if (!a || !hasValue(sheet, a.r, a.c)) continue;
|
||||
if (vertical ? a.c === at.c : a.r === at.r) out.push(vertical ? a.r : a.c);
|
||||
}
|
||||
return out.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Ctrl+방향 — 값 사이를 뛰어 끝으로(엑셀: 값 칸 뭉치의 끝 · 빈 칸이면 다음 값 · 없으면 시트 끝) */
|
||||
function jump(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
|
||||
const vertical = dr !== 0;
|
||||
const d = vertical ? dr : dc;
|
||||
const pos = vertical ? at.r : at.c;
|
||||
const edge = d > 0 ? (vertical ? LAST_R : LAST_C) : 0;
|
||||
const set = new Set(occupied(sheet, at, vertical));
|
||||
const has = (i: number): boolean => set.has(i);
|
||||
const put = (i: number): CellAddress => (vertical ? { r: i, c: at.c } : { r: at.r, c: i });
|
||||
if (pos === edge) return at;
|
||||
if (has(pos) && has(pos + d)) {
|
||||
let i = pos;
|
||||
while (i !== edge && has(i + d)) i += d;
|
||||
return put(i);
|
||||
}
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const next = d > 0 ? sorted.find((i) => i > pos) : sorted.reverse().find((i) => i < pos);
|
||||
return put(next ?? edge);
|
||||
}
|
||||
|
||||
export interface MoveOptions {
|
||||
/** Shift — 닻은 두고 움직이는 끝만 */
|
||||
extend?: boolean;
|
||||
/** Ctrl — 값 끝으로 */
|
||||
jump?: boolean;
|
||||
}
|
||||
|
||||
/** 방향키 · Home 류 공통 — 새 고름 */
|
||||
export function move(
|
||||
sheet: Sheet,
|
||||
sel: Selection,
|
||||
dr: number,
|
||||
dc: number,
|
||||
opts: MoveOptions = {},
|
||||
): Selection {
|
||||
const list = merges(sheet);
|
||||
if (!opts.extend) {
|
||||
const from = sel.활성;
|
||||
const m = mergeAt(sheet, from.r, from.c);
|
||||
// 병합에서 나갈 때는 병합 끝에서 한 칸
|
||||
const edge = { r: dr > 0 ? m.r1 : m.r0, c: dc > 0 ? m.c1 : m.c0 };
|
||||
const to = opts.jump ? jump(sheet, edge, dr, dc) : step(sheet, edge, dr, dc);
|
||||
if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c);
|
||||
return selectCell(sheet, to.r, to.c);
|
||||
}
|
||||
const g = sel.범위[0] ?? span(sel.활성, sel.활성);
|
||||
const anchor = sel.기준;
|
||||
// 움직이는 끝 = 닻에서 먼 쪽
|
||||
const far = {
|
||||
r: Math.abs(g.r1 - anchor.r) >= Math.abs(anchor.r - g.r0) ? g.r1 : g.r0,
|
||||
c: Math.abs(g.c1 - anchor.c) >= Math.abs(anchor.c - g.c0) ? g.c1 : g.c0,
|
||||
};
|
||||
let to = opts.jump ? jump(sheet, far, dr, dc) : step(sheet, far, dr, dc);
|
||||
// 줄이는 쪽이면 병합 너머까지 한 번에
|
||||
for (let guard = 0; guard < 50; guard++) {
|
||||
const next = expand(span(anchor, to), list);
|
||||
if (!sameRange(next, g) || to === far) break;
|
||||
to = step(sheet, to, dr, dc);
|
||||
}
|
||||
return {
|
||||
시트: sheet.id,
|
||||
범위: [expand(span(anchor, to), list)],
|
||||
활성: { ...sel.활성 },
|
||||
기준: { ...anchor },
|
||||
};
|
||||
}
|
||||
|
||||
export const sameRange = (a: CellRange, b: CellRange): boolean =>
|
||||
a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1;
|
||||
|
||||
/** 쓰인 칸 끝(Ctrl+End) */
|
||||
export function usedEnd(sheet: Sheet): CellAddress {
|
||||
let r = 0;
|
||||
let c = 0;
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const a = parseA1(key);
|
||||
if (!a) continue;
|
||||
r = Math.max(r, a.r);
|
||||
c = Math.max(c, a.c);
|
||||
}
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
/** 고름 안 Enter/Tab 돌기 — 범위가 한 칸(또는 병합 하나)이면 null(밖으로 옮김) */
|
||||
export function cycle(sheet: Sheet, sel: Selection, dr: number, dc: number): Selection | null {
|
||||
const g = sel.범위[0];
|
||||
if (!g) return null;
|
||||
const list = merges(sheet);
|
||||
if (list.some((m) => sameRange(m, g)) || (g.r0 === g.r1 && g.c0 === g.c1)) return null;
|
||||
// 행 전체 · 열 전체는 쓰인 칸 끝까지만 돎
|
||||
const end = usedEnd(sheet);
|
||||
const box = {
|
||||
...g,
|
||||
r1: g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1,
|
||||
c1: g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1,
|
||||
};
|
||||
let { r, c } = sel.활성;
|
||||
const cur = mergeAt(sheet, r, c);
|
||||
const rows = box.r1 - box.r0 + 1;
|
||||
const cols = box.c1 - box.c0 + 1;
|
||||
for (let n = 0; n < rows * cols; n++) {
|
||||
if (dr) {
|
||||
r += dr;
|
||||
if (r > box.r1) ((r = box.r0), (c = c + 1 > box.c1 ? box.c0 : c + 1));
|
||||
if (r < box.r0) ((r = box.r1), (c = c - 1 < box.c0 ? box.c1 : c - 1));
|
||||
} else {
|
||||
c += dc;
|
||||
if (c > box.c1) ((c = box.c0), (r = r + 1 > box.r1 ? box.r0 : r + 1));
|
||||
if (c < box.c0) ((c = box.c1), (r = r - 1 < box.r0 ? box.r1 : r - 1));
|
||||
}
|
||||
if (rowHidden(sheet, r) || colHidden(sheet, c)) continue;
|
||||
const m = mergeAt(sheet, r, c);
|
||||
if (m.r0 !== r || m.c0 !== c || sameRange(m, cur)) continue;
|
||||
return { ...sel, 범위: sel.범위.map((x) => ({ ...x })), 활성: { r, c } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고름 안 칸 전부(행열 전체는 쓰인 끝까지) — Ctrl+Enter · 지우기 대상 */
|
||||
export function cellsOf(sheet: Sheet, sel: Selection): CellAddress[] {
|
||||
const end = usedEnd(sheet);
|
||||
const out: CellAddress[] = [];
|
||||
for (const g of sel.범위) {
|
||||
const r1 = g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1;
|
||||
const c1 = g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1;
|
||||
for (let r = g.r0; r <= r1; r++) for (let c = g.c0; c <= c1; c++) out.push({ r, c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 스프레드시트 D 화면 시험 틀 입구 — 견본 통합문서를 띄우고 window.ss* 로 수치를 꺼냄(ORCA eval).
|
||||
* 번들: node helper_spreadsheet_d_bundle.cjs harness → tmp/spreadsheet_d/harness.js */
|
||||
const { createSpreadsheet } = require("../../../A00_Common/spreadsheet/spreadsheet");
|
||||
const sample = require("../../../A00_Common/spreadsheet/spreadsheet_sample.json");
|
||||
|
||||
const host = document.getElementById("host");
|
||||
window.ssSelect = null;
|
||||
window.ssChanges = 0;
|
||||
window.ssSample = JSON.parse(JSON.stringify(sample));
|
||||
window.ss = createSpreadsheet(host, JSON.parse(JSON.stringify(sample)), {
|
||||
onChange: () => window.ssChanges++,
|
||||
onSelect: (s) => (window.ssSelect = s),
|
||||
});
|
||||
window.ssCreate = createSpreadsheet;
|
||||
/** 칸 입력 값(A1) */
|
||||
window.ssCell = (a1, sheet = "s1") =>
|
||||
window.ss.getDoc().시트.find((s) => s.id === sheet).칸[a1] ?? null;
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>스프레드시트 D 시험 틀</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#host {
|
||||
height: 560px;
|
||||
margin: 8px;
|
||||
border: 1px solid #999;
|
||||
}
|
||||
#fakes {
|
||||
margin: 8px;
|
||||
color: #bc4c00;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fakes"></div>
|
||||
<div id="host"></div>
|
||||
<script src="../../../tmp/spreadsheet_d/harness.js"></script>
|
||||
<script>
|
||||
document.getElementById("fakes").textContent =
|
||||
"가짜 부품: " + (window.__ssFakes || []).join(", ");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
/* 스프레드시트 D 순수 규칙 시험 도우미 — 고름 · 입력 글 규칙 · 가리키기 자리 · 채우기 끌기 목표.
|
||||
* pytest(test_spreadsheet_d_input.py)가 부르고 결과 JSON 을 판정. */
|
||||
const path = require("path");
|
||||
const { load, SS } = require("./helper_spreadsheet_d_bundle.cjs");
|
||||
|
||||
const sel = load(path.join(SS, "spreadsheet_selection.ts"));
|
||||
const ed = load(path.join(SS, "spreadsheet_editor.ts"));
|
||||
const mouse = load(path.join(SS, "spreadsheet_mouse.ts"));
|
||||
const addr = load(path.join(SS, "spreadsheet_address.ts"));
|
||||
const sample = require(path.join(SS, "spreadsheet_sample.json"));
|
||||
|
||||
const s1 = sample.시트[0];
|
||||
const a1 = (s) => `${addr.toA1(s.활성.r, s.활성.c)}|${addr.rangeToA1(s.범위[0])}`;
|
||||
const cell = (r, c) => sel.selectCell(s1, r, c);
|
||||
const keys = (start, list) => {
|
||||
let s = start;
|
||||
return list.map(([dr, dc, o]) => a1((s = sel.move(s1, s, dr, dc, o || {}))));
|
||||
};
|
||||
|
||||
const out = {
|
||||
// 병합 C4:F4 안을 누르면 병합 전체 · 활성은 왼위
|
||||
mergeClick: a1(cell(3, 4)),
|
||||
// 병합에서 오른쪽 → G4 · 왼쪽 → B4
|
||||
mergeMove: keys(cell(3, 2), [
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
[0, -1],
|
||||
]),
|
||||
// Shift+→ 두 번(병합 걸침) · Shift+← 줄임 · Shift+↓
|
||||
shiftExtend: keys(cell(3, 0), [
|
||||
[0, 1, { extend: true }],
|
||||
[0, 1, { extend: true }],
|
||||
[0, -1, { extend: true }],
|
||||
[1, 0, { extend: true }],
|
||||
]),
|
||||
// Ctrl+↓ 값 뭉치 끝 → 시트 끝 · Ctrl+↑ 되돌아 값
|
||||
ctrlJump: keys(cell(2, 0), [
|
||||
[1, 0, { jump: true }],
|
||||
[1, 0, { jump: true }],
|
||||
[-1, 0, { jump: true }],
|
||||
]),
|
||||
// 숨긴 열 J 건너뜀(I → K)
|
||||
hiddenSkip: keys(cell(0, 8), [[0, 1]]).map((x) => x.split("|")[0]),
|
||||
// 열 머리 C — 병합 C3:F3 걸쳐 C:F
|
||||
colHead: addr.rangeToA1(sel.selectCols(s1, 2, 2).범위[0]),
|
||||
// 고름 안 Enter 돌기(열 먼저) · Tab(행 먼저)
|
||||
cycleEnter: (() => {
|
||||
let s = {
|
||||
시트: "s1",
|
||||
범위: [{ r0: 10, c0: 10, r1: 11, c1: 11 }],
|
||||
활성: { r: 10, c: 10 },
|
||||
기준: { r: 10, c: 10 },
|
||||
};
|
||||
return [1, 1, 1, 1].map(() => addr.toA1((s = sel.cycle(s1, s, 1, 0)).활성.r, s.활성.c));
|
||||
})(),
|
||||
cycleTab: (() => {
|
||||
let s = {
|
||||
시트: "s1",
|
||||
범위: [{ r0: 10, c0: 10, r1: 11, c1: 11 }],
|
||||
활성: { r: 10, c: 10 },
|
||||
기준: { r: 10, c: 10 },
|
||||
};
|
||||
return [1, 1, 1].map(() => addr.toA1((s = sel.cycle(s1, s, 0, 1)).활성.r, s.활성.c));
|
||||
})(),
|
||||
cycleSingle: sel.cycle(s1, cell(3, 2), 1, 0),
|
||||
usedEnd: addr.toA1(sel.usedEnd(s1).r, sel.usedEnd(s1).c),
|
||||
// 입력 글 → 칸
|
||||
textToCell: ["12", "1,234.5", "50%", "-1e3", "true", "'=12", "=", "=A1+1", "", "1,23", "abc"].map(
|
||||
(t) => ed.textToCell(t, { 서식: 4 }),
|
||||
),
|
||||
textToCellNoStyle: ed.textToCell("", {}),
|
||||
cellToText: [
|
||||
{ 값: "=12" },
|
||||
{ 값: "12" },
|
||||
{ 값: "abc" },
|
||||
{ 값: 1.5 },
|
||||
{ 값: false },
|
||||
{ 식: "A1*2" },
|
||||
undefined,
|
||||
].map((c) => ed.cellToText(c)),
|
||||
// 참조 넣을 자리
|
||||
insertable: [
|
||||
["=", 1],
|
||||
["=A1+", 4],
|
||||
["=A1", 3],
|
||||
["=SUM(", 5],
|
||||
["=SUM(A1,", 8],
|
||||
["=1 + ", 5],
|
||||
["abc", 3],
|
||||
["=A1+B", 4],
|
||||
].map(([t, p]) => ed.insertableAt(t, p)),
|
||||
// 참조 색 — 같은 글은 같은 색 · 다른 시트
|
||||
highlights: ed
|
||||
.refHighlights("=A1+B2:C3*A1+치수!B1", "s1", (n) => (n === "치수" ? "s2" : null))
|
||||
.map((h) => `${h.시트}:${addr.rangeToA1(h.범위)}:${h.색번호}`),
|
||||
// 채우기 끌기 목표
|
||||
fill: [[{ r: 9, c: 1 }], [{ r: 2, c: 6 }], [{ r: 3, c: 1 }], [{ r: 2, c: 1 }]].map(([p]) => {
|
||||
const t = mouse.fillTarget({ r0: 1, c0: 1, r1: 3, c1: 2 }, p);
|
||||
return [addr.rangeToA1(t.target), t.clear && addr.rangeToA1(t.clear)];
|
||||
}),
|
||||
};
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -0,0 +1,127 @@
|
||||
/* 스프레드시트 D 시험 번들 도우미 — TS 를 그 자리에서 CommonJS 로 풀어 모음.
|
||||
* 남의 몫 파일이 아직 빈 몸(「아직 없음」)이면 spreadsheet_d_fakes.cjs 의 가짜로 바꿔 끼움.
|
||||
* load(entry) Node 안에서 모듈을 돌려 exports 를 줌(pytest 도우미가 씀)
|
||||
* node 이 파일 harness 브라우저 시험 틀 번들 → tmp/spreadsheet_d/harness.js (git 밖)
|
||||
* `.cjs` = 루트 package.json 의 "type":"module" 과 무관하게 CommonJS. */
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const SS = path.join(ROOT, "A00_Common", "spreadsheet");
|
||||
const FAKES = path.join(__dirname, "spreadsheet_d_fakes.cjs");
|
||||
|
||||
const isStub = (file) => fs.readFileSync(file, "utf8").includes("아직 없음(");
|
||||
|
||||
/** 가져오기 글 → 모듈 id(파일 경로 · `fake:<이름>` · `css:<경로>`) */
|
||||
function resolve(spec, from) {
|
||||
if (spec === "@ui/ui_template_elements") return "fake:el";
|
||||
let abs;
|
||||
if (spec.startsWith("@ui/")) abs = path.join(ROOT, "ui_template", spec.slice(4));
|
||||
else if (spec.startsWith(".")) abs = path.resolve(path.dirname(from), spec);
|
||||
else throw new Error(`모르는 가져오기: ${spec} (${from})`);
|
||||
if (/\.(css|json|cjs)$/.test(abs)) return (abs.endsWith(".css") ? "css:" : "") + abs;
|
||||
if (!abs.endsWith(".ts")) abs += ".ts";
|
||||
if (path.dirname(abs) === SS && isStub(abs))
|
||||
return "fake:" + path.basename(abs, ".ts").replace(/^spreadsheet_/, "");
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** 입구부터 딸린 모듈 전부 → id → {code, deps} */
|
||||
function collect(entry, stubs = []) {
|
||||
const mods = new Map();
|
||||
const walk = (id) => {
|
||||
if (mods.has(id)) return;
|
||||
let code;
|
||||
if (id.startsWith("fake:")) {
|
||||
const name = id.slice(5);
|
||||
if (name !== "el") stubs.push(name);
|
||||
code = `module.exports = require("${FAKES.replace(/\\/g, "/")}")[${JSON.stringify(name)}];`;
|
||||
mods.set(id, { code, deps: { [FAKES.replace(/\\/g, "/")]: FAKES } });
|
||||
walk(FAKES);
|
||||
return;
|
||||
}
|
||||
if (id.startsWith("css:")) {
|
||||
const css = fs.readFileSync(id.slice(4), "utf8");
|
||||
code = `if (typeof document !== "undefined") { const s = document.createElement("style"); s.textContent = ${JSON.stringify(css)}; document.head.append(s); }`;
|
||||
mods.set(id, { code, deps: {} });
|
||||
return;
|
||||
}
|
||||
const src = fs.readFileSync(id, "utf8");
|
||||
if (id.endsWith(".json")) code = `module.exports = ${src};`;
|
||||
else if (id.endsWith(".ts"))
|
||||
code = ts.transpileModule(src, {
|
||||
fileName: id,
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
else code = src;
|
||||
const deps = {};
|
||||
mods.set(id, { code, deps });
|
||||
for (const m of code.matchAll(/require\("([^"]+)"\)/g)) {
|
||||
const dep = m[1] === FAKES.replace(/\\/g, "/") ? FAKES : resolve(m[1], id);
|
||||
deps[m[1]] = dep;
|
||||
walk(dep);
|
||||
}
|
||||
};
|
||||
walk(entry);
|
||||
return mods;
|
||||
}
|
||||
|
||||
/** Node 안에서 돌림 */
|
||||
function load(entry) {
|
||||
const abs = path.resolve(entry);
|
||||
const id = resolve("./" + path.basename(abs).replace(/\.ts$/, ""), abs); // 빈 몸이면 가짜
|
||||
const mods = collect(id);
|
||||
const cache = new Map();
|
||||
const req = (id) => {
|
||||
if (cache.has(id)) return cache.get(id).exports;
|
||||
const m = mods.get(id);
|
||||
const module = { exports: {} };
|
||||
cache.set(id, module);
|
||||
new Function("exports", "module", "require", m.code)(module.exports, module, (s) =>
|
||||
req(m.deps[s]),
|
||||
);
|
||||
return module.exports;
|
||||
};
|
||||
return req(id);
|
||||
}
|
||||
|
||||
/** 브라우저 한 파일 */
|
||||
function bundle(entry, out) {
|
||||
const stubs = [];
|
||||
const mods = collect(path.resolve(entry), stubs);
|
||||
const ids = [...mods.keys()];
|
||||
const defs = ids
|
||||
.map((id, i) => {
|
||||
const m = mods.get(id);
|
||||
const map = Object.fromEntries(Object.entries(m.deps).map(([s, d]) => [s, ids.indexOf(d)]));
|
||||
return `[${JSON.stringify(map)}, function (exports, module, require) {\n${m.code}\n}]`;
|
||||
})
|
||||
.join(",\n");
|
||||
const text = `(function () {
|
||||
const defs = [${defs}];
|
||||
const cache = [];
|
||||
function req(i) {
|
||||
if (cache[i]) return cache[i].exports;
|
||||
const module = (cache[i] = { exports: {} });
|
||||
defs[i][1](module.exports, module, (s) => req(defs[i][0][s]));
|
||||
return module.exports;
|
||||
}
|
||||
window.__ssFakes = ${JSON.stringify([...new Set(stubs)])};
|
||||
req(${ids.indexOf(path.resolve(entry))});
|
||||
})();\n`;
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, text);
|
||||
return { out, modules: ids.length, fakes: [...new Set(stubs)] };
|
||||
}
|
||||
|
||||
module.exports = { load, bundle, ROOT, SS };
|
||||
|
||||
if (require.main === module && process.argv[2] === "harness") {
|
||||
const r = bundle(
|
||||
path.join(__dirname, "harness_spreadsheet_d.cjs"),
|
||||
path.join(ROOT, "tmp", "spreadsheet_d", "harness.js"),
|
||||
);
|
||||
console.log(JSON.stringify(r));
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/* 스프레드시트 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") 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) },
|
||||
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";
|
||||
if (typeof ctx.engine.value(sheet.id, r, c) === "number") 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 },
|
||||
address,
|
||||
parser,
|
||||
refshift,
|
||||
fill,
|
||||
commands,
|
||||
history,
|
||||
graph,
|
||||
grid,
|
||||
toolbar: { mountToolbar: none },
|
||||
tabs: { mountTabs },
|
||||
menu: { attachMenu: none },
|
||||
clipboard: {
|
||||
attachClipboard: none,
|
||||
parseClipboard: () => null,
|
||||
blockToClipboard: () => ({ html: "", text: "" }),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""스프레드시트 D 입력 — 고름 · 입력 글 규칙 · 참조 가리키기 자리 · 채우기 끌기 목표(엑셀 동작 기준).
|
||||
|
||||
Node 도우미(helper_spreadsheet_d.cjs)가 TS 를 풀어 돌리고 JSON 을 줌 — 남의 몫이 빈 몸이면 가짜 부품.
|
||||
화면 조작(한글 첫 글자 · 수식 입력줄 · 끌기)은 harness_spreadsheet_d.html 을 ORCA 로.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_spreadsheet_d.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_merge_selection(r):
|
||||
assert r["mergeClick"] == "C4|C4:F4"
|
||||
assert r["mergeMove"] == ["G4|G4", "C4|C4:F4", "B4|B4"]
|
||||
assert r["colHead"] == "A:H" # 걸친 병합(A1:H1 · C3:F3 · A6:G6)만큼 늘림
|
||||
|
||||
|
||||
def test_shift_ctrl_arrows(r):
|
||||
assert r["shiftExtend"] == ["A4|A4:B4", "A4|A4:F4", "A4|A4:B4", "A4|A4:B5"]
|
||||
assert r["ctrlJump"] == ["A6|A6:G6", "A1048576|A1048576", "A6|A6:G6"]
|
||||
assert r["hiddenSkip"] == ["K1"]
|
||||
assert r["usedEnd"] == "J6"
|
||||
|
||||
|
||||
def test_cycle_in_selection(r):
|
||||
assert r["cycleEnter"] == ["K12", "L11", "L12", "K11"]
|
||||
assert r["cycleTab"] == ["L11", "K12", "L12"]
|
||||
assert r["cycleSingle"] is None
|
||||
|
||||
|
||||
def test_text_to_cell(r):
|
||||
got = r["textToCell"]
|
||||
assert [c.get("값", c.get("식")) for c in got] == [
|
||||
12,
|
||||
1234.5,
|
||||
0.5,
|
||||
-1000,
|
||||
True,
|
||||
"=12",
|
||||
"=",
|
||||
"A1+1",
|
||||
None,
|
||||
"1,23",
|
||||
"abc",
|
||||
]
|
||||
assert "식" in got[7] and all(c["서식"] == 4 for c in got)
|
||||
assert r["textToCellNoStyle"] is None
|
||||
|
||||
|
||||
def test_cell_to_text(r):
|
||||
assert r["cellToText"] == ["'=12", "'12", "abc", "1.5", "FALSE", "=A1*2", ""]
|
||||
|
||||
|
||||
def test_pointing(r):
|
||||
assert r["insertable"] == [True, True, False, True, True, True, False, False]
|
||||
assert r["highlights"] == ["s1:A1:0", "s1:B2:C3:1", "s1:A1:0", "s2:B1:2"]
|
||||
|
||||
|
||||
def test_fill_target(r):
|
||||
assert r["fill"] == [
|
||||
["B2:C10", None],
|
||||
["B2:G4", None],
|
||||
["B2:B4", "C2:C4"],
|
||||
["B2:C3", "B4:C4"],
|
||||
]
|
||||
Reference in New Issue
Block a user