diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css new file mode 100644 index 00000000..1266725d --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -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; +} diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 8374051b..061d08b7 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -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(); + + 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): 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(); + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts new file mode 100644 index 00000000..08411ba7 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -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(); + 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 = {}; + 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(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts new file mode 100644 index 00000000..2e5cdfcb --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -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 = { + 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); + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts new file mode 100644 index 00000000..5d0e706b --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -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); + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts new file mode 100644 index 00000000..64449dcf --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -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; +} diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.cjs b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs new file mode 100644 index 00000000..8d220b14 --- /dev/null +++ b/resources/tester/spreadsheet/harness_spreadsheet_d.cjs @@ -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; diff --git a/resources/tester/spreadsheet/harness_spreadsheet_d.html b/resources/tester/spreadsheet/harness_spreadsheet_d.html new file mode 100644 index 00000000..be9d4089 --- /dev/null +++ b/resources/tester/spreadsheet/harness_spreadsheet_d.html @@ -0,0 +1,32 @@ + + + + + 스프레드시트 D 시험 틀 + + + +
+
+ + + + diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs new file mode 100644 index 00000000..cc09a435 --- /dev/null +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -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)); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d_bundle.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d_bundle.cjs new file mode 100644 index 00000000..1540441e --- /dev/null +++ b/resources/tester/spreadsheet/helper_spreadsheet_d_bundle.cjs @@ -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)); +} diff --git a/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs b/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs new file mode 100644 index 00000000..2bc09d98 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs @@ -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: "" }), + }, +}; diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py new file mode 100644 index 00000000..c4a5f38d --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -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"], + ]