/* ============================================================================= * spreadsheet_clipboard.ts (주인 E) * 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀). * 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라내기 = 복사 + `내용지움`(누른 순간 * 바로 지움) · 붙여넣기 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령 * 묶음(칸 하나하나). ⚠ 잘라내기는 OS 클립보드를 거치는 비동기 사건(다른 창 · 다른 앱에 붙일 수 있음)이라 * `옮기기` 명령(같은 문서 안에서 그 칸을 가리키던 다른 식도 따라감) 은 못 씀 — 잘라낸 칸을 가리키던 * 다른 칸의 식은 안 따라옴(엑셀의 「점선 테두리 유지 · 붙일 때만 지움」 도 아님). 편집 중 * (`ctx.editor.editing()`)이면 사건을 편집기에 맡김. * * 이음(D `spreadsheet.ts` 잇는 자리) — `attachClipboard(ctx)` 한 번 부름(반환 `root` 없음 · 안 붙여도 됨). * `ctx.root` 의 copy/cut/paste 사건을 직접 받으므로 그 요소가 포커스를 받을 수 있어야 함 * (D 의 숨은 textarea 가 늘 그 안에 있어야 함). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { colName, parseRange, toA1 } from "./spreadsheet_address"; import { formatValue } from "./spreadsheet_numfmt"; import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; import { st } from "./spreadsheet_text"; import type { BorderLine, Cell, CellAddress, CellInput, CellRange, CellStyle, ClipBlock, ClipCell, Command, Scalar, Sheet, Workbook, } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; /* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */ const BORDER_CSS: Record = { hair: "dotted", thin: "solid", medium: "solid", thick: "solid", double: "double", dotted: "dotted", dashed: "dashed", }; function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle { const cell = sheet.칸[toA1(r, c)]; const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0; return book.서식[idx] ?? {}; } function styleToCss(style: CellStyle): string { const parts: string[] = []; if (style.글꼴) parts.push(`font-family:${style.글꼴}`); if (style.크기) parts.push(`font-size:${style.크기}pt`); if (style.굵게) parts.push("font-weight:bold"); if (style.기울임) parts.push("font-style:italic"); const deco = [style.밑줄 && "underline", style.취소선 && "line-through"] .filter(Boolean) .join(" "); if (deco) parts.push(`text-decoration:${deco}`); if (style.글자색) parts.push(`color:${style.글자색}`); if (style.채움) parts.push(`background-color:${style.채움}`); if (style.가로 && style.가로 !== "general") { parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`); } if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`); if (style.줄바꿈) parts.push("white-space:pre-wrap"); if (style.테두리) { (["위", "아래", "왼", "오른"] as const).forEach((side) => { const s = style.테두리?.[side]; if (!s) return; const prop = { 위: "border-top", 아래: "border-bottom", 왼: "border-left", 오른: "border-right", }[side]; parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`); }); } return parts.join(";"); } function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } /** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */ function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock { const { r0, c0, r1, c1 } = range; const 칸: Record = {}; for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const cell = sheet.칸[toA1(r, c)]; if (!cell) continue; const clip: ClipCell = {}; if (cell.값 !== undefined) clip.값 = cell.값; if (cell.식 !== undefined) clip.식 = cell.식; if (cell.서식 !== undefined) clip.서식 = book.서식[cell.서식]; if (Object.keys(clip).length) 칸[`${r - r0},${c - c0}`] = clip; } } const 병합 = (sheet.병합 ?? []) .map((m) => parseRange(m)) .filter((m): m is CellRange => !!m && m.r0 >= r0 && m.c0 >= c0 && m.r1 <= r1 && m.c1 <= c1) .map((m) => ({ r0: m.r0 - r0, c0: m.c0 - c0, r1: m.r1 - r0, c1: m.c1 - c0 })); return { rows: r1 - r0 + 1, cols: c1 - c0 + 1, 칸, 병합, 원점: { r: r0, c: c0 }, 출처: "aislo" }; } /** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON). */ export function blockToClipboard( book: Workbook, sheetId: string, range: CellRange, valueAt: (r: number, c: number) => Scalar, ): { html: string; text: string } { const sheet = book.시트.find((s) => s.id === sheetId); if (!sheet) return { html: "", text: "" }; const { r0, c0, r1, c1 } = range; const aisloBlock = buildAisloBlock(book, sheet, range); const rowsHtml: string[] = []; const tsvRows: string[] = []; const skip = new Set(); for (let r = r0; r <= r1; r++) { const cellsHtml: string[] = []; const tsvCells: string[] = []; for (let c = c0; c <= c1; c++) { const key = `${r},${c}`; if (skip.has(key)) continue; const style = styleAt(book, sheet, r, c); const text = formatValue(valueAt(r, c), style.형식).글; tsvCells.push(text.replace(/\t/g, " ")); let span = ""; const merge = (sheet.병합 ?? []) .map((m) => parseRange(m)) .find((m): m is CellRange => !!m && m.r0 === r && m.c0 === c && m.r1 <= r1 && m.c1 <= c1); if (merge) { const rs = merge.r1 - merge.r0 + 1; const cs = merge.c1 - merge.c0 + 1; if (rs > 1) span += ` rowspan="${rs}"`; if (cs > 1) span += ` colspan="${cs}"`; for (let dr = 0; dr < rs; dr++) for (let dc = 0; dc < cs; dc++) skip.add(`${r + dr},${c + dc}`); } cellsHtml.push( `${escapeHtml(text)}`, ); } rowsHtml.push(`${cellsHtml.join("")}`); tsvRows.push(tsvCells.join("\t")); } const table = `${rowsHtml.join("")}
`; const html = `${table}`; return { html, text: tsvRows.join("\r\n") }; } /* ── 밖 → 안 ─────────────────────────────────────────────────────────────── */ /** 「1,234.56」 · 「TRUE」 는 값으로 · 나머지는 글로. */ function coerceValue(text: string): CellInput { const t = text.trim(); if (/^[+-]?\d[\d,]*(\.\d+)?$/.test(t)) { const n = Number(t.replace(/,/g, "")); if (Number.isFinite(n)) return n; } if (/^(TRUE|FALSE)$/i.test(t)) return t.toUpperCase() === "TRUE"; return text; } /** 「=」 뒤에 글이 있고 식으로 보이면 식 — 엑셀 「수식 표시」 복사 대응(「=」 한 글자는 글). */ function cellFromText(text: string): ClipCell { if (text.startsWith("=") && text.length > 1) return { 식: text.slice(1) }; return { 값: coerceValue(text) }; } interface TableWalk { rows: number; cols: number; owner: Map; merges: CellRange[]; } function walkTable(table: HTMLTableElement): TableWalk { const occupied = new Set(); const owner = new Map(); const merges: CellRange[] = []; const rows = Array.from(table.rows); rows.forEach((row, r) => { let c = 0; Array.from(row.cells).forEach((cellEl) => { while (occupied.has(`${r},${c}`)) c++; const rowspan = cellEl.rowSpan || 1; const colspan = cellEl.colSpan || 1; owner.set(`${r},${c}`, cellEl); for (let dr = 0; dr < rowspan; dr++) { for (let dc = 0; dc < colspan; dc++) occupied.add(`${r + dr},${c + dc}`); } if (rowspan > 1 || colspan > 1) { merges.push({ r0: r, c0: c, r1: r + rowspan - 1, c1: c + colspan - 1 }); } c += colspan; }); }); let cols = 0; for (const key of occupied) { const c = Number(key.split(",")[1]); if (c + 1 > cols) cols = c + 1; } return { rows: rows.length, cols, owner, merges }; } function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null { const doc = new DOMParser().parseFromString(html, "text/html"); const table = doc.querySelector("table"); if (!table) return null; const walk = walkTable(table); const sheetsMode = /data-sheets-formula/.test(html); const 칸: Record = {}; for (const [key, cellEl] of walk.owner) { const [r, c] = key.split(",").map(Number); const text = cellEl.textContent ?? ""; const formula = sheetsMode ? cellEl.getAttribute("data-sheets-formula") : null; if (formula) { const target = { r: at.r + r, c: at.c + c }; 칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) }; continue; } if (!text) continue; 칸[key] = cellFromText(text); } return { rows: walk.rows, cols: walk.cols, 칸, 병합: walk.merges, 원점: null, 출처: sheetsMode ? "google" : "excel", }; } function parseTsv(text: string): ClipBlock { const lines = text.replace(/\r\n/g, "\n").split("\n"); while (lines.length && lines[lines.length - 1] === "") lines.pop(); const grid = lines.map((line) => line.split("\t")); let cols = 0; for (const row of grid) if (row.length > cols) cols = row.length; const 칸: Record = {}; grid.forEach((row, r) => { row.forEach((text, c) => { if (text === "") return; 칸[`${r},${c}`] = cellFromText(text); }); }); return { rows: grid.length, cols, 칸, 병합: [], 원점: null, 출처: "text" }; } /** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이). */ export function parseClipboard(html: string, text: string, at: CellAddress): ClipBlock | null { const aislo = //.exec(html); if (aislo) { try { return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock; } catch { // 손상됐으면 아래 일반 경로로 폴백. } } if (/ = {}; const styleCmds: Command[] = []; for (const [key, clip] of Object.entries(block.칸)) { const [lr, lc] = key.split(",").map(Number); const r = at.r + lr; const c = at.c + lc; const cell: Cell = {}; if (clip.값 !== undefined) cell.값 = clip.값; if (clip.식 !== undefined) cell.식 = shift ? moveFormula(clip.식, shift.dr, shift.dc) : clip.식; 칸[toA1(r, c)] = Object.keys(cell).length ? cell : null; if (clip.서식) { styleCmds.push({ 종류: "서식", 시트: ctx.selection.시트, 범위: [{ r0: r, c0: c, r1: r, c1: c }], 바꿀: clip.서식, }); } } const commands: Command[] = [{ 종류: "칸", 시트: ctx.selection.시트, 칸 }, ...styleCmds]; for (const m of block.병합) { commands.push({ 종류: "병합", 시트: ctx.selection.시트, 범위: { r0: at.r + m.r0, c0: at.c + m.c0, r1: at.r + m.r1, c1: at.c + m.c1 }, }); } ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }); } /** `ctx.root` 의 copy · cut · paste 를 받음(root null) */ export function attachClipboard(ctx: SpreadsheetContext): PartHandle { function onCopy(ev: ClipboardEvent): void { if (ctx.editor.editing()) return; const range = activeRange(ctx); const { html, text } = blockToClipboard(ctx.book, ctx.selection.시트, range, (r, c) => ctx.engine.value(ctx.selection.시트, r, c), ); ev.clipboardData?.setData("text/html", html); ev.clipboardData?.setData("text/plain", text); ev.preventDefault(); } function onCut(ev: ClipboardEvent): void { if (ctx.editor.editing() || ctx.readOnly) return; onCopy(ev); ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [activeRange(ctx)] }); } function onPaste(ev: ClipboardEvent): void { if (ctx.editor.editing() || ctx.readOnly) return; const html = ev.clipboardData?.getData("text/html") ?? ""; const text = ev.clipboardData?.getData("text/plain") ?? ""; const at = ctx.selection.활성; const block = parseClipboard(html, text, at); if (!block) { showToast(st("ClipboardPasteFailed"), "error"); return; } ev.preventDefault(); pasteBlock(ctx, block, at); } ctx.root.addEventListener("copy", onCopy); ctx.root.addEventListener("cut", onCut); ctx.root.addEventListener("paste", onPaste); return { root: null, refresh() {}, destroy() { ctx.root.removeEventListener("copy", onCopy); ctx.root.removeEventListener("cut", onCut); ctx.root.removeEventListener("paste", onPaste); }, }; }