- 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기 - 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기 - 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용) - 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML) - E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_clipboard.ts (주인 E)
|
|
* 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀).
|
|
* 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라 붙이기(안쪽) = `옮기기` 명령 ·
|
|
* 나머지 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령 묶음.
|
|
* 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김.
|
|
* ========================================================================== */
|
|
|
|
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<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)]?.서식 ?? 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 };
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
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<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 pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
|
|
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[] = [{ 종류: "칸", 시트: 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);
|
|
},
|
|
};
|
|
}
|