/* ============================================================================= * 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(); }, }; }