|
|
|
@@ -20,6 +20,7 @@ import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
|
|
|
|
|
import { st } from "./spreadsheet_text";
|
|
|
|
|
import type {
|
|
|
|
|
BorderLine,
|
|
|
|
|
BorderSide,
|
|
|
|
|
Cell,
|
|
|
|
|
CellAddress,
|
|
|
|
|
CellInput,
|
|
|
|
@@ -48,7 +49,12 @@ const BORDER_CSS: Record<BorderLine, string> = {
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
const idx =
|
|
|
|
|
cell?.서식 ??
|
|
|
|
|
sheet.열?.[colName(c)]?.서식 ??
|
|
|
|
|
sheet.행?.[String(r + 1)]?.서식 ??
|
|
|
|
|
sheet.기본?.서식 ??
|
|
|
|
|
0;
|
|
|
|
|
return book.서식[idx] ?? {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -213,12 +219,108 @@ function walkTable(table: HTMLTableElement): TableWalk {
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BORDER_STYLE_MAP: Record<string, BorderLine> = {
|
|
|
|
|
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 색 ? { 선, 색 } : { 선 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** `<style>...</style>` 안 `.xl65 {...}` 짜임(엑셀이 인라인 대신 클래스로 서식을 실을 때) → 클래스별 선언. */
|
|
|
|
|
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(/\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g)) {
|
|
|
|
|
rules[m[1]] = parseStyleAttr(m[2]);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
@@ -229,8 +331,14 @@ function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null {
|
|
|
|
|
칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) };
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!text) continue;
|
|
|
|
|
칸[key] = cellFromText(text);
|
|
|
|
|
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,
|
|
|
|
@@ -286,7 +394,48 @@ function activeRange(ctx: SpreadsheetContext): CellRange {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[] = [];
|
|
|
|
@@ -307,7 +456,11 @@ function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress):
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const commands: Command[] = [{ 종류: "칸", 시트: ctx.selection.시트, 칸 }, ...styleCmds];
|
|
|
|
|
const commands: Command[] = [
|
|
|
|
|
...unmergeCmds,
|
|
|
|
|
{ 종류: "칸", 시트: ctx.selection.시트, 칸 },
|
|
|
|
|
...styleCmds,
|
|
|
|
|
];
|
|
|
|
|
for (const m of block.병합) {
|
|
|
|
|
commands.push({
|
|
|
|
|
종류: "병합",
|
|
|
|
|