/* ============================================================================= * spreadsheet_editor.ts (주인 D) * 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) · * 참조 칸 색 테두리. * 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을 * 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지). * 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 · * 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈. * ========================================================================== */ import { el, showToast } from "@ui/ui_template_elements"; import { moveFormula } from "./spreadsheet_refshift"; import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; import type { FuncHelp } from "./spreadsheet_func_help"; import { parseFormula, tokenize } from "./spreadsheet_parser"; import { cellsOf, move, selectCell } from "./spreadsheet_selection"; import { checkValue, findValidation } from "./spreadsheet_validation"; import { cellStyleAt } from "./spreadsheet_cellstyle"; import type { Cell, CellAddress, CellStyle, Command, 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; /** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */ accept(): boolean; /** 자동 완성 목록 안 위아래(목록이 없으면 false) */ suggestMove(d: number): boolean; /** 고름 칸 전부에 같은 입력(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 }; } /** 친 모양대로 숫자 형식(엑셀) — `50%` → `0%` · `12.5%` → `0.0%` · `1,234.5` → `#,##0.0` · 아니면 null */ export function typedFormat(text: string): string | null { const t = text.trim(); if (parseNumber(t) === null || /e/i.test(t)) return null; const dec = /\.(\d+)/.exec(t)?.[1].length ?? 0; const frac = dec ? "." + "0".repeat(dec) : ""; if (t.endsWith("%")) return `0${frac}%`; if (t.includes(",")) return `#,##0${frac}`; return null; } /** 식 글(앞 `=` 뺌) — 함수 · 참조 · 참거짓을 대문자로(엑셀) · 시트 이름 · 글 · 정의된 이름은 그대로 */ export function upperFormula(body: string): string { return tokenize(body) .map((t) => { if (t.kind === "func" || t.kind === "bool") return t.text.toUpperCase(); if (t.kind !== "ref") return t.text; const bang = t.text.lastIndexOf("!") + 1; return t.text.slice(0, bang) + t.text.slice(bang).toUpperCase(); }) .join(""); } /** 닫는 괄호만 모자란 식이면 채운 식(엑셀 자동 고침) · 아니면 null */ export function closeParens(value: string): string | null { let depth = 0; for (const t of tokenize(value.slice(1))) if (t.kind === "paren") depth += t.text === "(" ? 1 : -1; if (depth <= 0) return null; const fixed = value + ")".repeat(depth); return parseFormula(fixed).ok ? fixed : null; } /** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */ 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]); } /** F4 — caret 에 걸친 참조의 $ 를 A1 → $A$1 → A$1 → $A1 → A1 로(범위면 두 끝 같이) · caret 은 참조 끝 */ export function cycleAbs(text: string, pos: number): { text: string; caret: number } | null { if (text[0] !== "=") return null; const t = tokenize(text.slice(1)).find( (t) => t.kind === "ref" && t.start < pos && pos <= t.end + 1, ); if (!t) return null; const bang = t.text.lastIndexOf("!") + 1; const part = /(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g; const first = part.exec(t.text.slice(bang)); if (!first) return null; const state = (first[1] ? 1 : 0) + (first[3] ? 2 : 0); // 0 없음 · 3 둘 다 · 2 행만 · 1 열만 const next = { 0: 3, 3: 2, 2: 1, 1: 0 }[state]!; const ref = t.text.slice(0, bang) + t.text .slice(bang) .replace( part, (_m, _a, col, _b, row) => (next & 1 ? "$" : "") + col + (next & 2 ? "$" : "") + row, ); const end = t.start + 1 + ref.length; return { text: text.slice(0, t.start + 1) + ref + text.slice(t.end + 1), caret: end }; } /** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */ 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] ?? {}; } /** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */ export function formulaHint( pre: string, ): { prefix: string } | { func: string; arg: number } | null { if (pre[0] !== "=") return null; const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre); if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] }; let depth = 0; let arg = 0; let inStr = false; for (let i = pre.length - 1; i > 0; i--) { const ch = pre[i]; if (ch === '"') inStr = !inStr; if (inStr) continue; if (ch === ")") depth++; else if (ch === "," && depth === 0) arg++; else if (ch === "(") { if (depth-- > 0) continue; const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i)); return n ? { func: n[1].toUpperCase(), arg } : null; } } return null; } /** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서 * 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */ export function slotOf( ctx: SpreadsheetContext, r: number, c: number, ): { layer: HTMLElement; box: CellBox } { const { layer, box } = ctx.grid.editorSlot(r, c); const a = layer.getBoundingClientRect(); const g = ctx.grid.root.getBoundingClientRect(); return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } }; } /** 칸 자리 → 층 안 자리 */ 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 } = slotOf(ctx, 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) : []); showHint(); } // ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ──────────────── const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } }); let sugg: FuncHelp[] = []; let pick = 0; let typed = ""; let fh: typeof import("./spreadsheet_func_help") | null = null; // 식을 처음 칠 때 불러옴 — 크기 예산 function showHint(): void { const node = src(); const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null; if (h && !fh) return void import("./spreadsheet_func_help").then((m) => ((fh = m), showHint())); sugg = h && "prefix" in h ? fh!.suggestFunctions(h.prefix).slice(0, 8) : []; typed = h && "prefix" in h ? h.prefix : ""; const help = h && "func" in h ? fh!.funcHelp(h.func) : null; if (!sugg.length && !help) return void hint.setAttribute("hidden", ""); pick = Math.min(pick, Math.max(0, sugg.length - 1)); hint.replaceChildren( ...(sugg.length ? sugg.map((f, i) => { const row = el("div", { className: i === pick ? "ss-hint__item is-on" : "ss-hint__item", children: [el("b", { text: f.이름 }), ` ${f.뜻}`], }); row.addEventListener("mousedown", (e) => { e.preventDefault(); pick = i; accept(); }); return row; }) : [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]), ); if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint); hint.style.left = ta.style.left; hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`; hint.removeAttribute("hidden"); } function accept(): boolean { if (!st || !sugg.length) return false; const node = src(); const pos = node.selectionEnd; const name = `${sugg[pick].이름}(`; node.setRangeText(name, pos - typed.length, pos, "end"); (node === ta ? formula : ta).value = node.value; pick = 0; place(); highlight(); return true; } /** 편집 시작 — `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([]); hint.setAttribute("hidden", ""); sync(); focus(); } /** 칸에 적음 — 식이 틀렸거나(인자 수 등) 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */ function write(cells: CellAddress[], value: string, from: CellAddress): boolean { if (!st) return true; const isFormula = value.length > 1 && value[0] === "="; if (isFormula && !parseFormula(value).ok) value = closeParens(value) ?? value; const parsed = isFormula ? parseFormula(value) : null; if (parsed?.ok) value = "=" + upperFormula(value.slice(1)); if (parsed && !parsed.ok) { showToast(parsed.message, "error"); const node = src(); const at = Math.min(parsed.at + 1, node.value.length); st.mode = "edit"; // 방향키가 글자 사이를 움직이게 st.point = null; node.setSelectionRange(at, at); return false; } const sheet = sheetOf(st.sheet); const out: Record = {}; const warn = new Set(); 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; const rule = next?.값 !== undefined ? findValidation(sheet, a.r, a.c) : undefined; const check = rule && checkValue(rule, next!.값!); if (check && !check.ok) { if (check.어기면 === "막음") return (showToast(check.메시지, "error"), false); warn.add(check.메시지); } out[key] = next; } for (const m of warn) showToast(m, "warning"); if (!Object.keys(out).length) return true; const cellCmd: Command = { 종류: "칸", 시트: sheet.id, 칸: out }; const fmt = isFormula ? null : typedFormat(value); // `%` 는 글 형식(`@`) · 이미 % 형식만 빼고 바꿈 · 천 단위 쉼표는 일반 형식일 때만(엑셀) const takes = (code?: string): boolean => !code || /^(general|g\/표준)$/i.test(code) || (fmt!.endsWith("%") && !code.includes("%") && code.trim() !== "@"); const fmtAt = fmt ? cells.filter((a) => takes(cellStyleAt(ctx.book, sheet, a.r, a.c).형식)) : []; ctx.dispatch( fmtAt.length ? { 종류: "묶음", 명령: [ cellCmd, { 종류: "서식", 시트: sheet.id, 범위: fmtAt.map((a) => ({ r0: a.r, c0: a.c, r1: a.r, c1: a.c })), 바꿀: { 형식: fmt! }, }, ], } : cellCmd, ); return true; } function commit(): void { if (!st) return; const value = text(); if (value !== st.original && !write([st.at], value, st.at)) return; end(); } function commitAll(): void { if (!st) return; if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return; 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(); }); /** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */ async function nameBox(name: string): Promise { const { defineName, resolveName } = await import("./spreadsheet_names"); // 늦게 — 크기 예산 const hit = resolveName(ctx.book, ctx.selection.시트, name); if (hit) { if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트); const at = { r: hit.범위.r0, c: hit.범위.c0 }; return ctx.select({ 시트: hit.시트, 범위: [hit.범위], 활성: at, 기준: { ...at } }, true); } const why = ctx.readOnly ? null : defineName(ctx, name, ctx.selection.범위[0]); if (why) showToast(why, "error"); sync(); } 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 void nameBox(value.trim()); 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, accept, suggestMove(d: number) { if (!st || !sugg.length) return false; pick = (pick + d + sugg.length) % sugg.length; showHint(); return true; }, focus, place() { if (st) place(); }, destroy() { hint.remove(); ta.remove(); bar.remove(); }, }; }