feat(m02): 도면 → 면적 칸 크기 기본 · 넣을 자리 고르기(105-1)

- 도면 표 받은 뒤 작은 창 [새 시트] / [고른 칸부터] · 칸 크기 · 글 크기는 기본(도면 mm 안 옮김)
- 스프레드시트 공개 손잡이 pasteTable — 붙여넣기 길(parseClipboard + pasteBlock) · 덮을 값 · 알약 칸 셈(pasteHits)
- 값 · 알약 이은 칸을 덮으면 물음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-08 16:15:54 +09:00
co-authored by Claude Opus 5.5
parent ef00ee6431
commit 01d819bfb6
6 changed files with 381 additions and 41 deletions
@@ -1,18 +1,28 @@
/* =============================================================================
* M02_MasterTemplete_AreaFromDrawing.ts
* 도면 → 면적산출 한 번 옮기기(PLAN 83-3 · 연동 아님) — 오른쪽 도면 패널(`AreaDrawing`)이 받은 표 자료를
* 면적 통합문서의 새 시트(「도면에서 n」)로 만듦. 기존 시트는 안 덮음 · 병합 · 열 폭 · 행 높이 반영.
* 면적 통합문서로 — 받은 뒤 작은 창 [새 시트](「도면에서 n」) / [고른 칸부터](지금 시트 · 덮으면 물음 · 105-1).
* 병합만 옮김 — 칸 크기 · 글 크기는 기본(도면 크기 안 옮김).
* 표 자료 꼴 = CAD `helpers/table-from-drawing.ts` 의 `DrawingTable`(rows · merges · colWidths · rowHeights · 도면 mm).
* 스프레드시트 부품은 안 고침 — 시트 자료만 만들고 싣기는 공개 손잡이 `addSheet`(되돌리기 한 번 · onChange → 화면 고침 표시).
* 싣기는 공개 손잡이 — 새 시트 `addSheet` · 고른 칸 `pasteTable`(붙여넣기 길) · 둘 다 되돌리기 한 번 · onChange.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import type { PasteCheck } from "../A00_Common/spreadsheet/spreadsheet_clipboard";
import type { Cell, Sheet, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
const TEXT = {
done: (name: string) =>
`도면 표를 「${name}」 시트로 옮김 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`,
pasted: (range: string) => `도면 표를 ${range} 에 넣음 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`,
loose: "표 선을 못 찾아 글 자리로 줄 · 칸을 나눔 — 칸이 맞는지 확인",
where: (rows: number, cols: number) => `도면 표(${rows}행 × ${cols}열)를 어디에 넣을지`,
cancel: "취소",
atCell: "고른 칸부터",
newSheet: "새 시트",
overwrite: (range: string, cells: number, links: number) =>
`${range} 에 값 있는 칸 ${cells}개${links ? ` · 알약 이은 칸 ${links}개` : ""} — 덮어씀?`,
overwriteOk: "덮어쓰기",
noHandle: "스프레드시트가 시트 추가를 아직 못 함 — 옮기지 않음",
noSheet: "면적산출 시트가 열려 있지 않음",
};
@@ -28,12 +38,6 @@ export interface DrawingTable {
export const SHEET_PREFIX = "도면에서";
/** 도면 mm → 엑셀 열 폭(글자 단위 · 1글자 ≈ 7 px ≈ 1.85 mm) · 행 높이(pt = mm × 72 / 25.4) */
const CHAR_PER_MM = 0.54;
const PT_PER_MM = 72 / 25.4;
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
const round1 = (v: number): number => Math.round(v * 10) / 10;
/** 0 → `A` · 27 → `AB`(`spreadsheet_address.colName` 과 같은 규칙) */
function colName(c: number): string {
let s = "";
@@ -59,7 +63,7 @@ function nextSheetId(book: Pick<Workbook, "시트">): string {
return `s${n}`;
}
/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */
/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것 · 칸 크기 · 글 크기는 기본) */
export function tableToSheet(table: DrawingTable, book: Pick<Workbook, "시트">): Sheet {
const 칸: Record<string, Cell> = {};
table.rows.forEach((line, r) =>
@@ -73,39 +77,123 @@ export function tableToSheet(table: DrawingTable, book: Pick<Workbook, "시트">
.map((m) => `${a1(m.row, m.col)}:${a1(m.row + m.rowSpan - 1, m.col + m.colSpan - 1)}`);
const sheet: Sheet = { id: nextSheetId(book), 이름: nextSheetName(book), 칸 };
if (병합.length) sheet.병합 = 병합;
if (table.colWidths.length) {
sheet.열 = {};
table.colWidths.forEach((w, c) => {
if (w > 0) sheet.열![colName(c)] = { 폭: round1(clamp(w * CHAR_PER_MM, 2, 60)) };
});
}
if (table.rowHeights.length) {
sheet.행 = {};
table.rowHeights.forEach((h, r) => {
if (h > 0) sheet.행![String(r + 1)] = { 높이: round1(clamp(h * PT_PER_MM, 12, 100)) };
});
}
return sheet;
}
/** 스프레드시트 공개 손잡이 중 쓰는 것 — 시트 더하기(되돌리기 한 번 · onChange · 탭 줄은 부품 몫) */
const escape = (v: string | number): string =>
String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/** 표 자료 → `<table>` 글(병합 = rowspan · colspan) — 손잡이 `pasteTable` 이 붙여넣기 길로 읽음 */
export function tableToHtml(table: DrawingTable): string {
const cols = Math.max(0, ...table.rows.map((line) => line.length));
const span = new Map<string, DrawingTable["merges"][number]>();
const covered = new Set<string>();
for (const m of table.merges) {
span.set(`${m.row},${m.col}`, m);
for (let r = m.row; r < m.row + m.rowSpan; r += 1)
for (let c = m.col; c < m.col + m.colSpan; c += 1)
if (r !== m.row || c !== m.col) covered.add(`${r},${c}`);
}
const rows = table.rows.map((line, r) => {
let tds = "";
for (let c = 0; c < cols; c += 1) {
if (covered.has(`${r},${c}`)) continue;
const m = span.get(`${r},${c}`);
const attrs =
(m && m.rowSpan > 1 ? ` rowspan="${m.rowSpan}"` : "") +
(m && m.colSpan > 1 ? ` colspan="${m.colSpan}"` : "");
tds += `<td${attrs}>${escape(line[c] ?? "")}</td>`;
}
return `<tr>${tds}</tr>`;
});
return `<table>${rows.join("")}</table>`;
}
export type Where = "sheet" | "cell" | null;
/** 넣을 자리 작은 창 — [취소] [고른 칸부터] [새 시트](주 단추 · 기본) · Esc = 취소 */
export function askWhere(rows: number, cols: number): Promise<Where> {
return new Promise((resolve) => {
const overlay = el("div", { className: "ui-confirm" });
const done = (got: Where): void => {
overlay.remove();
resolve(got);
};
const main = createButton({
label: TEXT.newSheet,
variant: "filled",
onClick: () => done("sheet"),
});
const actions = el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }),
createButton({ label: TEXT.atCell, onClick: () => done("cell") }),
main,
],
});
overlay.dataset.areaWhere = "";
overlay.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape") return;
ev.stopPropagation();
done(null);
});
overlay.append(
el("div", {
className: "ui-confirm__panel",
children: [
el("p", { className: "ui-confirm__message", text: TEXT.where(rows, cols) }),
actions,
],
}),
);
document.body.append(overlay);
main.focus();
});
}
/** 덮을 칸(값 · 알약)이 있으면 물음 */
const askOverwrite: PasteCheck = (hit) =>
!hit.칸 && !hit.연결
? true
: showConfirmDialog(TEXT.overwrite(hit.범위, hit.칸, hit.연결), TEXT.overwriteOk);
/** 스프레드시트 공개 손잡이 중 쓰는 것 — 새 시트(탭 줄은 부품 몫) · 고른 칸부터 붙이기 */
export interface AddSheetHandle {
getDoc(): Workbook;
addSheet?: (sheet: Sheet, opts?: { activate?: boolean }) => unknown;
pasteTable?: (html: string, check?: PasteCheck) => Promise<string | null>;
}
/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */
export function importDrawingTable(
/** 받은 표 → 넣을 자리 물음 → 새 시트(활성) 또는 지금 시트 고른 칸부터 · 알림 —
* 옮긴 시트 이름 · 붙인 범위 · 못 하거나 그만두면 null */
export async function importDrawingTable(
handle: AddSheetHandle | null,
table: DrawingTable,
): string | null {
choose: (rows: number, cols: number) => Promise<Where> = askWhere,
): Promise<string | null> {
if (!handle?.addSheet) {
showToast(handle ? TEXT.noHandle : TEXT.noSheet, "error");
return null;
}
const sheet = tableToSheet(table, handle.getDoc());
handle.addSheet(sheet, { activate: true });
showToast(TEXT.done(sheet.이름), "success");
const cols = Math.max(0, ...table.rows.map((line) => line.length));
const where = await choose(table.rows.length, cols);
if (!where) return null;
let done: string | null;
if (where === "cell") {
if (!handle.pasteTable) {
showToast(TEXT.noHandle, "error");
return null;
}
done = await handle.pasteTable(tableToHtml(table), askOverwrite);
if (!done) return null;
showToast(TEXT.pasted(done), "success");
} else {
const sheet = tableToSheet(table, handle.getDoc());
handle.addSheet(sheet, { activate: true });
done = sheet.이름;
showToast(TEXT.done(done), "success");
}
if (table.kind === "text") showToast(TEXT.loose, "info");
return sheet.이름;
return done;
}