Files
Aislo/A00_Common/spreadsheet/spreadsheet_editor.ts
T
eomsangdonandClaude Opus 5.5 a2011c8035 feat(spreadsheet): D 입력 — 고름 · 칸 편집기 · 한글 조합 · 수식 입력줄 · 참조 가리키기 · 단축키 · 채우기 끌기 · 잇기
- spreadsheet.ts — createSpreadsheet 잇기(문맥 · 명령 → 되돌림 → 엔진 → 격자 · onChange · onSelect · 시트마다 고름 기억) · parts 한 곳(시험 틀이 빈 몸 부품을 바꿔 끼움)
- spreadsheet_selection.ts — 병합으로 늘림 · Shift/Ctrl 방향 · 숨김 건너뜀 · 행열 머리 · 고름 안 Enter/Tab 돌기
- spreadsheet_editor.ts — 숨은 textarea 늘 초점 · compositionstart/input 로 편집 시작 · enter/edit 방식 · 수식 입력줄 · 주소 상자 · 참조 넣기 · 색 테두리 · 입력 글 규칙(' · % · 천 단위)
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 엑셀 단축키 · 끌기 고르기 · 채우기 핸들(늘림 · 줄여 지움 · 두 번 누르면 아래로) · 우클릭 자리
- 시험 resources/tester/spreadsheet — 규칙 7 묶음 · 가짜 부품 번들 시험 틀 html(ORCA)

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

402 lines
14 KiB
TypeScript

/* =============================================================================
* spreadsheet_editor.ts (주인 D)
* 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) ·
* 참조 칸 색 테두리.
* 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을
* 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지).
* 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 ·
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { moveFormula } from "./spreadsheet_refshift";
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { tokenize } from "./spreadsheet_parser";
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
import type {
CellBox,
EditorHandle,
RefHighlight,
Selection,
SpreadsheetContext,
} from "./spreadsheet_view_types";
export interface Editor extends EditorHandle {
/** 숨은 textarea = 칸 편집기(늘 초점) */
ta: HTMLTextAreaElement;
/** 주소 상자 + 수식 입력줄 한 줄 */
bar: HTMLElement;
formula: HTMLTextAreaElement;
mode(): "enter" | "edit";
toggleMode(): void;
/** 지금 글자 자리에 참조를 넣을 수 있나(식 · 연산자 뒤) */
pointable(): boolean;
/** 가리킨 고름을 식 글에 넣음(같은 가리키기면 바꿔 적음) */
point(sel: Selection): void;
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
commitAll(): void;
/** 초점 되찾기(스크롤 없이) */
focus(): void;
/** 편집 중이면 칸 자리 다시(스크롤 · 폭 바뀜) */
place(): void;
destroy(): void;
}
interface EditState {
sheet: string;
at: CellAddress;
mode: "enter" | "edit";
original: string;
/** 가리키기 중 — 식 글 [start, end) 가 가리킨 참조 */
point: { start: number; end: number; sel: Selection } | null;
}
/** 수 입력 — 1,234 · -1.5e3 · 50% (엑셀 입력 규칙) · 아니면 null */
export function parseNumber(text: string): number | null {
let t = text.trim();
const pct = t.endsWith("%");
if (pct) t = t.slice(0, -1);
if (!/\d/.test(t) || !/^[+-]?(\d{1,3}(,\d{3})+|\d*)(\.\d*)?([eE][+-]?\d+)?$/.test(t)) return null;
const n = Number(t.replace(/,/g, ""));
if (!Number.isFinite(n)) return null;
return pct ? n / 100 : n;
}
const looksTyped = (s: string): boolean =>
(s.length > 1 && s[0] === "=") ||
s[0] === "'" ||
parseNumber(s) !== null ||
/^(true|false)$/i.test(s);
/** 친 글 → 칸(서식은 두고) · 지울 칸이면 null */
export function textToCell(text: string, old?: Cell): Cell | null {
const base: Cell = old?.서식 !== undefined ? { 서식: old.서식 } : {};
if (text === "") return old?.서식 !== undefined ? base : null;
if (text.length > 1 && text[0] === "=") return { ...base, 식: text.slice(1) };
if (text[0] === "'") return { ...base, 값: text.slice(1) };
const n = parseNumber(text);
if (n !== null) return { ...base, 값: n };
if (/^(true|false)$/i.test(text)) return { ...base, 값: text.toUpperCase() === "TRUE" };
return { ...base, 값: text };
}
/** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */
export function cellToText(cell: Cell | undefined): string {
if (!cell) return "";
if (cell.식 !== undefined) return "=" + cell.식;
const v = cell.값;
if (v === undefined) return "";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (typeof v === "number") return String(v);
return looksTyped(v) ? "'" + v : v;
}
/** 참조 넣을 자리 — 식이고 caret 앞(빈칸 건너)이 연산자 · 괄호 · 쉼표 · `=` */
export function insertableAt(text: string, pos: number): boolean {
if (text[0] !== "=") return false;
if (pos < text.length && /[A-Za-z0-9$_.!']/.test(text[pos])) return false;
let i = pos - 1;
while (i >= 0 && text[i] === " ") i--;
return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]);
}
/** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */
export function refHighlights(
text: string,
host: string,
sheetId: (name: string) => string | null,
): RefHighlight[] {
if (text[0] !== "=") return [];
const out: RefHighlight[] = [];
const colors = new Map<string, number>();
for (const t of tokenize(text.slice(1))) {
if (t.kind !== "ref") continue;
const bang = t.text.lastIndexOf("!");
let sheet: string | null = host;
if (bang >= 0) {
const raw = t.text.slice(0, bang);
const name = raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw;
sheet = sheetId(name);
}
const range = parseRange(t.text.slice(bang + 1).replace(/\$/g, ""));
if (!sheet || !range) continue;
const key = t.text.toUpperCase();
if (!colors.has(key)) colors.set(key, colors.size);
out.push({ 시트: sheet, 범위: range, 색번호: colors.get(key)! });
}
return out;
}
/** 칸 서식(칸 → 행 → 열 → 기본) */
function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): CellStyle {
const idx =
sheet.칸[toA1(r, c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
0;
return ctx.book.서식[idx] ?? {};
}
/** editorSlot 칸 자리 → 층 안 자리 */
export function boxStyle(node: HTMLElement, box: CellBox): void {
node.style.left = `${box.x}px`;
node.style.top = `${box.y}px`;
node.style.minWidth = `${box.w}px`;
node.style.minHeight = `${box.h}px`;
}
export function createEditor(ctx: SpreadsheetContext): Editor {
const ta = el("textarea", {
className: "ss-input ss-input--idle",
attrs: { spellcheck: "false", autocomplete: "off", rows: "1", "aria-label": "칸 입력" },
});
const addr = el("input", {
className: "ss-addr",
attrs: { spellcheck: "false", "aria-label": "주소 상자" },
});
const formula = el("textarea", {
className: "ss-formula",
attrs: { spellcheck: "false", rows: "1", "aria-label": "수식 입력줄" },
});
const bar = el("div", {
className: "ss-bar",
children: [addr, el("span", { className: "ss-fx", text: "fx" }), formula],
});
formula.readOnly = ctx.readOnly;
ta.readOnly = ctx.readOnly;
let st: EditState | null = null;
const src = (): HTMLTextAreaElement => (document.activeElement === formula ? formula : ta);
const text = (): string => src().value;
const sheetOf = (id: string): Sheet => ctx.book.시트.find((s) => s.id === id) ?? ctx.sheet();
const sheetId = (name: string): string | null =>
ctx.book.시트.find((s) => s.이름.toUpperCase() === name.toUpperCase())?.id ?? null;
function place(): void {
const at = st?.at ?? ctx.selection.활성;
const { layer, box } = ctx.grid.editorSlot(at.r, at.c);
if (ta.parentElement !== layer) {
const had = document.activeElement === ta;
layer.append(ta);
if (had) focus();
}
boxStyle(ta, box);
if (!st) return;
const s = styleAt(ctx, sheetOf(st.sheet), at.r, at.c);
ta.style.fontFamily = s.글꼴 ?? "";
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0";
ta.style.height = "0";
ta.style.width = `${Math.max(box.w, ta.scrollWidth + 4)}px`;
ta.style.height = `${Math.max(box.h, ta.scrollHeight)}px`;
}
function focus(): void {
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
}
function highlight(): void {
const t = st ? text() : "";
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
}
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
function start(mode: "enter" | "edit", value?: string, from: HTMLTextAreaElement = ta): void {
if (ctx.readOnly || st) return;
const sel = ctx.selection;
const sheet = ctx.sheet();
const original = cellToText(sheet.칸[toA1(sel.활성.r, sel.활성.c)]);
st = { sheet: sheet.id, at: { ...sel.활성 }, mode, original, point: null };
if (value !== undefined) from.value = value;
(from === ta ? formula : ta).value = from.value;
ta.classList.remove("ss-input--idle");
ctx.root.classList.add("ss--editing");
place();
highlight();
if (from === ta && value !== undefined) ta.setSelectionRange(ta.value.length, ta.value.length);
}
function end(): void {
st = null;
ta.value = "";
ta.classList.add("ss-input--idle");
ta.removeAttribute("style");
ctx.root.classList.remove("ss--editing");
ctx.grid.setRefHighlights([]);
sync();
focus();
}
function write(cells: CellAddress[], value: string, from: CellAddress): void {
if (!st) return;
const sheet = sheetOf(st.sheet);
const out: Record<string, Cell | null> = {};
for (const a of cells) {
let v = value;
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
v = "=" + moveFormula(v.slice(1), a.r - from.r, a.c - from.c);
const key = toA1(a.r, a.c);
const next = textToCell(v, sheet.칸[key]);
if (next === null && !sheet.칸[key]) continue;
out[key] = next;
}
if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out });
}
function commit(): void {
if (!st) return;
const value = text();
if (value !== st.original) write([st.at], value, st.at);
end();
}
function commitAll(): void {
if (!st) return;
write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at);
end();
}
function cancel(): void {
if (!st) return;
end();
}
function sync(): void {
const sel = ctx.selection;
addr.value = toA1(sel.활성.r, sel.활성.c);
if (st) return;
formula.value = cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]);
place();
}
function pointable(): boolean {
if (!st) return false;
const node = src();
if (node.selectionStart !== node.selectionEnd) return false;
const pos = node.selectionEnd;
if (st.point && st.point.end === pos) return true;
st.point = null;
return insertableAt(node.value, pos);
}
function point(sel: Selection): void {
if (!st) return;
const node = src();
const other = node === ta ? formula : ta;
const pos = node.selectionEnd;
const p = st.point ?? { start: pos, end: pos, sel };
const g = sel.범위[0];
const ref = rangeToA1(g);
node.value = node.value.slice(0, p.start) + ref + node.value.slice(p.end);
other.value = node.value;
st.point = { start: p.start, end: p.start + ref.length, sel };
node.setSelectionRange(st.point.end, st.point.end);
place();
highlight();
ctx.grid.reveal(sel.활성.r === g.r0 ? g.r1 : g.r0, sel.활성.c === g.c0 ? g.c1 : g.c0);
}
function pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void {
if (!st) return;
const sheet = sheetOf(st.sheet);
const from = st.point?.sel ?? selectCell(sheet, st.at.r, st.at.c);
point(move(sheet, from, dr, dc, { extend, jump }));
}
// ── 한글 조합 · 바로 치기 → 편집 시작 ─────────────────────────────────────
ta.addEventListener("compositionstart", () => {
if (!st) start("enter");
});
ta.addEventListener("input", () => {
if (!st) return start("enter");
formula.value = ta.value;
if (st.point && ta.selectionEnd !== st.point.end) st.point = null;
place();
highlight();
});
// 수식 입력줄 — 누르면 edit 방식으로 시작
formula.addEventListener("focus", () => {
if (!st && !ctx.readOnly) start("edit", formula.value, formula);
});
formula.addEventListener("input", () => {
if (!st) return start("edit", undefined, formula);
ta.value = formula.value;
if (st.point && formula.selectionEnd !== st.point.end) st.point = null;
place();
highlight();
});
// 주소 상자 — `B5` · `A1:C3` · `시트!B5` 로 가기
addr.addEventListener("focus", () => addr.select());
addr.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
sync();
focus();
}
if (e.key !== "Enter") return;
e.preventDefault();
goTo(addr.value.trim());
focus();
});
function goTo(value: string): void {
const bang = value.lastIndexOf("!");
let sheet = ctx.sheet();
if (bang >= 0) {
const raw = value.slice(0, bang);
const id = sheetId(raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw);
if (!id) return sync();
if (id !== sheet.id) ctx.showSheet(id);
sheet = ctx.sheet();
}
const body = value.slice(bang + 1).replace(/\$/g, "");
const one = parseA1(body);
const g = parseRange(body);
if (!g) return sync();
if (one) return ctx.select(selectCell(sheet, one.r, one.c), true);
const at = { r: g.r0, c: g.c0 };
ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true);
}
return {
ta,
bar,
formula,
editing: () => st !== null,
begin(initial?: string) {
if (initial === undefined) {
const sel = ctx.selection;
start("edit", cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]));
} else start("enter", initial);
focus();
},
commit,
commitAll,
cancel,
sync,
mode: () => st?.mode ?? "enter",
toggleMode() {
if (st) ((st.mode = st.mode === "enter" ? "edit" : "enter"), (st.point = null));
},
pointable,
point,
pointMove,
focus,
place() {
if (st) place();
},
destroy() {
ta.remove();
bar.remove();
},
};
}