/* ============================================================================= * 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, BorderSide, 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)]?.서식 ?? sheet.기본?.서식 ?? 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 }; } /** 엑셀 · 구글이 `td style="..."` 에 싣는 인라인 서식 → `CellStyle`(읽을 것 없으면 undefined). */ function parseStyleAttr(styleAttr: string): Record { const out: Record = {}; for (const decl of styleAttr.split(";")) { const i = decl.indexOf(":"); if (i < 0) continue; const key = decl.slice(0, i).trim().toLowerCase(); const value = decl.slice(i + 1).trim(); if (key) out[key] = value; } return out; } function cssColorToHex(v: string | undefined): string | undefined { if (!v) return undefined; const s = v.trim().toLowerCase(); if (!s || s === "windowtext" || s === "transparent" || s === "none" || s === "auto") return undefined; if (/^#[0-9a-f]{3}$/.test(s)) return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`; if (/^#[0-9a-f]{6}$/.test(s)) return s; const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s); if (rgb) { const hex = (n: string) => Number(n).toString(16).padStart(2, "0"); return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`; } return undefined; } /** 엑셀 `mso-number-format:"\#\,\#\#0\.00"` — 따옴표 떼고 `\x` 이스케이프 풀기. */ function unescapeMsoNumFmt(v: string): string { return v.replace(/^"|"$/g, "").replace(/\\(.)/g, "$1"); } const BORDER_STYLE_MAP: Record = { solid: "thin", dashed: "dashed", dotted: "dotted", double: "double", }; function parseBorderShorthand(v: string): BorderSide | undefined { const m = /([\d.]+)\s*(?:pt|px)\s+(solid|dashed|dotted|double)\s*([^;]*)/i.exec(v); if (!m) return undefined; const 선 = BORDER_STYLE_MAP[m[2].toLowerCase()] ?? "thin"; const 색 = cssColorToHex(m[3]); return 색 ? { 선, 색 } : { 선 }; } /** `` 안 `.xl65 {...}` 짜임(엑셀이 인라인 대신 클래스로 서식을 실을 때) → 클래스별 선언. */ function parseClassRules(doc: Document): Record> { const css = Array.from(doc.querySelectorAll("style")) .map((s) => s.textContent ?? "") .join("\n"); const rules: Record> = {}; for (const m of css.matchAll(/\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g)) { rules[m[1]] = parseStyleAttr(m[2]); } return rules; } function styleFromTd( cellEl: HTMLTableCellElement, classRules: Record>, ): CellStyle | undefined { const decls: Record = {}; for (const cls of cellEl.classList) Object.assign(decls, classRules[cls]); const raw = cellEl.getAttribute("style"); if (raw) Object.assign(decls, parseStyleAttr(raw)); if (!Object.keys(decls).length) return undefined; const style: CellStyle = {}; const weight = decls["font-weight"]; if (weight === "bold" || Number(weight) >= 700) style.굵게 = true; if (decls["font-style"] === "italic") style.기울임 = true; const color = cssColorToHex(decls["color"]); if (color) style.글자색 = color; const bg = cssColorToHex(decls["background-color"] ?? decls["background"]); if (bg) style.채움 = bg; const align = decls["text-align"]; if (align === "left" || align === "center" || align === "right") style.가로 = align; if (decls["mso-number-format"]) style.형식 = unescapeMsoNumFmt(decls["mso-number-format"]); const 테두리: NonNullable = {}; const sideMap: [string, "위" | "아래" | "왼" | "오른"][] = [ ["border-top", "위"], ["border-bottom", "아래"], ["border-left", "왼"], ["border-right", "오른"], ]; for (const [prop, side] of sideMap) { const b = parseBorderShorthand(decls[prop] ?? decls["border"] ?? ""); if (b) 테두리[side] = b; } if (Object.keys(테두리).length) style.테두리 = 테두리; return Object.keys(style).length ? style : undefined; } 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 classRules = parseClassRules(doc); 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; } const style = styleFromTd(cellEl, classRules); if (!text) { if (style) 칸[key] = { 서식: style }; continue; } const clip = cellFromText(text); if (style) clip.서식 = style; 칸[key] = clip; } 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 (/= b.r0 && a.c0 <= b.c1 && a.c1 >= b.c0; } function rangeContains(outer: CellRange, inner: CellRange): boolean { return ( outer.r0 <= inner.r0 && outer.r1 >= inner.r1 && outer.c0 <= inner.c0 && outer.c1 >= inner.c1 ); } /** 붙일 자리가 기존 병합의 일부만 걸치면 막음(엑셀 「병합된 셀의 일부를 바꿀 수 없습니다」) — 병합을 * 통째로 덮거나(허용) · 병합 한 칸에 값 하나만 얹는 것(허용)은 됨. */ function findBlockingMerge(sheet: Sheet, target: CellRange): boolean { const isSingleCell = target.r0 === target.r1 && target.c0 === target.c1; for (const m of sheet.병합 ?? []) { const mr = parseRange(m); if (!mr || !rangesOverlap(target, mr)) continue; if (rangeContains(target, mr)) continue; if (isSingleCell && rangeContains(mr, target)) continue; return true; } return false; } function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void { const target: CellRange = { r0: at.r, c0: at.c, r1: at.r + block.rows - 1, c1: at.c + block.cols - 1, }; const sheet = ctx.sheet(); if (findBlockingMerge(sheet, target)) { showToast(st("ClipboardMergeBlocked"), "error"); return; } // 붙일 자리가 기존 병합을 통째로 덮으면 먼저 풂(엑셀도 큰 덩어리를 얹으면 병합이 풀리고 칸마다 값이 옴) — // 안 풀면 A `applyCommand` 가 병합 속 왼위 아닌 칸에 값을 넣으려 할 때 막음. const unmergeCmds: Command[] = (sheet.병합 ?? []) .map((m) => parseRange(m)) .filter((m): m is CellRange => !!m && rangeContains(target, m)) .map((범위) => ({ 종류: "병합풀기", 시트: ctx.selection.시트, 범위 })); const shift = block.원점 ? { dr: at.r - block.원점.r, dc: at.c - block.원점.c } : null; const 칸: Record = {}; 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[] = [ ...unmergeCmds, { 종류: "칸", 시트: 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); }, }; }