- <style> 안 클래스 규칙 읽기가 「.xl65, .xl70 {...}」 같은 묶음 선택자에서 앞쪽 클래스를
놓치던 것 고침(전체 선택자를 쉼표로 나눠 각 클래스에 선언 병합)
- 테두리 pt 굵기 → 우리 선 종류 매핑 추가(.5pt 이하 hair · ~1.25pt thin · ~2pt medium · 그 위
thick, dashed·dotted·double 은 굵기와 상관없이 그 모양) — 「합계 줄 위 굵은 선」처럼 같은
칸 안 굵기가 다른 테두리를 구별해 살림
- 실제 엑셀 내보내기 짜임(머리 채움 · 가는 테두리 · 합계 줄 위 굵은 테두리 · #,##0 숫자 형식)을
재현한 새 견본 resources/tester/spreadsheet/fixture_excel_style_paste.html 로 시험
- ORCA 실 엔진 확인 — 머리 칸 굵게·채움 #d9d9d9·가운데 정렬·사방 hair, 숫자 칸 오른쪽 정렬·
형식 #,##0·사방 hair, 합계 줄 굵게·위 테두리만 medium(나머지 hair)까지 전부 반영 확인
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
531 lines
20 KiB
TypeScript
531 lines
20 KiB
TypeScript
/* =============================================================================
|
|
* 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<BorderLine, string> = {
|
|
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, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
|
|
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
|
|
const { r0, c0, r1, c1 } = range;
|
|
const 칸: Record<string, ClipCell> = {};
|
|
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<string>();
|
|
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(
|
|
`<td${span} style="${escapeHtml(styleToCss(style))}">${escapeHtml(text)}</td>`,
|
|
);
|
|
}
|
|
rowsHtml.push(`<tr>${cellsHtml.join("")}</tr>`);
|
|
tsvRows.push(tsvCells.join("\t"));
|
|
}
|
|
const table = `<table>${rowsHtml.join("")}</table>`;
|
|
const html = `<!--aislo:${encodeURIComponent(JSON.stringify(aisloBlock))}-->${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<string, HTMLTableCellElement>;
|
|
merges: CellRange[];
|
|
}
|
|
|
|
function walkTable(table: HTMLTableElement): TableWalk {
|
|
const occupied = new Set<string>();
|
|
const owner = new Map<string, HTMLTableCellElement>();
|
|
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<string, string> {
|
|
const out: Record<string, string> = {};
|
|
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");
|
|
}
|
|
|
|
/** pt 굵기 → 우리 선 종류(엑셀 내보내기 흔한 값 기준 — hairline ≈ .5pt 이하 · thin ≈ 1pt · medium ≈
|
|
* 1.5~2pt · thick ≈ 2pt 넘음). `solid` 아닌 것은 굵기와 상관없이 그 모양. */
|
|
function ptToBorderLine(pt: number, cssStyle: string): BorderLine {
|
|
if (cssStyle === "dashed") return "dashed";
|
|
if (cssStyle === "dotted") return "dotted";
|
|
if (cssStyle === "double") return "double";
|
|
if (pt <= 0.5) return "hair";
|
|
if (pt <= 1.25) return "thin";
|
|
if (pt <= 2) return "medium";
|
|
return "thick";
|
|
}
|
|
|
|
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 선 = ptToBorderLine(Number(m[1]), m[2].toLowerCase());
|
|
const 색 = cssColorToHex(m[3]);
|
|
return 색 ? { 선, 색 } : { 선 };
|
|
}
|
|
|
|
/** `<style>...</style>` 안 `.xl65 {...}` · `.xl65, .xl70 {...}`(묶음 선택자) 짜임(엑셀이 인라인 대신
|
|
* 클래스로 서식을 실을 때) → 클래스별 선언(같은 클래스가 여러 규칙에 나오면 뒤엣것이 이김). */
|
|
function parseClassRules(doc: Document): Record<string, Record<string, string>> {
|
|
const css = Array.from(doc.querySelectorAll("style"))
|
|
.map((s) => s.textContent ?? "")
|
|
.join("\n");
|
|
const rules: Record<string, Record<string, string>> = {};
|
|
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
|
const decls = parseStyleAttr(m[2]);
|
|
for (const selectorPart of m[1].split(",")) {
|
|
const cls = /\.([a-zA-Z0-9_-]+)\s*$/.exec(selectorPart.trim());
|
|
if (cls) Object.assign((rules[cls[1]] ??= {}), decls);
|
|
}
|
|
}
|
|
return rules;
|
|
}
|
|
|
|
function styleFromTd(
|
|
cellEl: HTMLTableCellElement,
|
|
classRules: Record<string, Record<string, string>>,
|
|
): CellStyle | undefined {
|
|
const decls: Record<string, string> = {};
|
|
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<CellStyle["테두리"]> = {};
|
|
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<string, ClipCell> = {};
|
|
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<string, ClipCell> = {};
|
|
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 = /<!--aislo:([^>]*)-->/.exec(html);
|
|
if (aislo) {
|
|
try {
|
|
return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock;
|
|
} catch {
|
|
// 손상됐으면 아래 일반 경로로 폴백.
|
|
}
|
|
}
|
|
if (/<table/i.test(html)) return parseHtmlTable(html, at);
|
|
if (text) return parseTsv(text);
|
|
return null;
|
|
}
|
|
|
|
/* ── 화면 붙임 — copy · cut · paste 사건 ────────────────────────────────── */
|
|
|
|
function activeRange(ctx: SpreadsheetContext): CellRange {
|
|
return (
|
|
ctx.selection.범위[0] ?? {
|
|
r0: ctx.selection.활성.r,
|
|
c0: ctx.selection.활성.c,
|
|
r1: ctx.selection.활성.r,
|
|
c1: ctx.selection.활성.c,
|
|
}
|
|
);
|
|
}
|
|
|
|
function rangesOverlap(a: CellRange, b: CellRange): boolean {
|
|
return a.r0 <= b.r1 && a.r1 >= 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<string, Cell | null> = {};
|
|
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);
|
|
},
|
|
};
|
|
}
|