feat(spreadsheet): 공개 손잡이 addSheet · 되돌리기 한 번 · 탭 없이 띄운 시트에 탭 줄 붙임 · 도면 표 → 면적 새 시트 검증(83-3)

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 13:00:01 +09:00
co-authored by Claude Opus 5.5
parent 9c3e1f8193
commit 152d97af15
2 changed files with 185 additions and 0 deletions
+41
View File
@@ -36,6 +36,7 @@ import type {
CalcValues,
Command,
CommandEffect,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
@@ -81,6 +82,10 @@ export interface SpreadsheetHandle {
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
* 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */
setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;
/** 시트 하나 덧붙임(끝 자리 · 83-3 도면 → 면적) — 되돌리기 한 번 · onChange · 이름 겹치면 「이름 (n)」 ·
* `activate` 면 새 시트로 · 탭 줄 없이 띄운 시트면 탭 줄을 붙임(시트 1장으로 돌아가면 숨김) ·
* 붙인 시트 이름 · 읽기만이면 null */
addSheet(sheet: Sheet, opts?: { activate?: boolean }): string | null;
destroy(): void;
}
@@ -154,6 +159,8 @@ export function createSpreadsheet(
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
const detachMouse = attachMouse(ctx, ed, extras.mouse);
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
let lateTabs: PartHandle | null = null;
const toast = el("div", { className: "ss-toast" });
let toastTimer = 0;
/** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */
@@ -303,6 +310,40 @@ export function createSpreadsheet(
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
return true;
},
addSheet(sheet, add) {
if (readOnly) return null;
const taken = (name: string) =>
ctx.book.시트.some((x) => x.이름.toLowerCase() === name.toLowerCase());
let 이름 = sheet.이름;
for (let n = 2; taken(이름); n += 1) 이름 = `${sheet.이름} (${n})`;
let id = sheet.id;
for (let n = ctx.book.시트.length + 1; ctx.book.시트.some((x) => x.id === id); n += 1)
id = `s${n}`;
const before = ctx.book.시트.length;
dispatch({ 종류: "시트더하기", 시트: { ...sheet, id, 이름 }, 자리: ctx.book.시트.length });
if (ctx.book.시트.length === before) return null; // 명령이 막힘(알림은 냄)
if (noTabs && !lateTabs) {
// 탭 줄 없이 띄운 시트 — 지금 붙이고 시트가 1장이면(되돌리기) 숨김
const part = mountTabs(ctx);
const bar = part.root;
const sync = (): void => {
const many = ctx.book.시트.length > 1;
if (bar) bar.hidden = !many;
root.classList.toggle("ss--notabs", !many);
};
lateTabs = {
root: part.root,
refresh: () => (part.refresh(), sync()),
destroy: () => part.destroy(),
};
if (bar) status.root ? status.root.before(bar) : root.append(bar);
others.push(lateTabs);
sync();
}
if (add?.activate) showSheet(id);
else for (const p of others) p.refresh();
return 이름;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
@@ -0,0 +1,144 @@
/* 83-3 — 받은 표 → 면적 새 시트(addSheet) · 탭 줄 · 되돌리기 한 번 · 화면 고침 표시
* 시작 화면: M02 · 시스템 층 임시 항목(「데스크탑보조임시」 · 시트 1장) 면적산출 상세 · 「도면」 패널 펼침
* 뒤: [저장] → area/<열>.json 새 시트 병합 B1:C1 · 열 폭 21.6/16.2/16.2 · 행 높이 28.3 · 다시 열기 = 탭 2장 · 임시 항목 지움
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: 패널 CAD 에 선 표 견본을 보기 전용으로 싣고(저장 안 됨) 끌어 고른 뒤 [면적산출로 보내기]
* → 탭 · 활성 시트 · 칸 글 · 병합 수를 읽음 → 되돌리기(↶) → 다시 읽음 → 다시 보냄(저장은 부른 쪽)
* 기대 수치:
* - before.tabs = 0(탭 줄 없음) · added.tabs = ["…", "도면에서 1"] · added.active = "도면에서 1"
* - added.cells = 구분 · 수량 · 흙깎기 · 1500 · m3 · 돌쌓기 · 2.5 · m2 · added.widths 비 = 40:30:30(156 · 118 · 118 px)
* - undone.tabsShown = false · undone.sheets = 1(탭 줄 숨음) · again.active = "도면에서 1"
* - 끝에 원래 도면을 다시 실음(restored)
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const frame = $('.ui-side-panels__panel[data-panel="drawing"] iframe');
const btn = $("[data-area-from-drawing]");
if (!frame || !btn) return JSON.stringify({ error: "도면 패널 · 단추 없음" });
const win = frame.contentWindow;
const cad = win.__aisloCad;
const L = "L83";
let n = 0;
const base = { lineColor: "#fff", lineWidth: 1, layerId: L };
const line = (x1, y1, x2, y2) => ({
id: `t83-${(n += 1)}`,
type: "Line",
...base,
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
});
const text = (label, x, y) => ({
id: `t83-${(n += 1)}`,
type: "Text",
...base,
shapeData: {
label,
basePoint: { x, y },
options: {
textDirection: { x: 1, y: 0 },
textAlign: "left",
textColor: "#fff",
fontSize: 2.5,
fontFamily: "Noto Sans KR",
},
},
});
const grid = [
line(100, 60, 200, 60),
line(100, 30, 200, 30),
line(100, 60, 100, 30),
line(200, 60, 200, 30),
line(100, 50, 200, 50),
line(100, 40, 200, 40),
line(140, 60, 140, 30),
line(170, 50, 170, 30),
text("구분", 102, 53),
text("수량", 145, 53),
text("흙깎기", 102, 43),
text("1,500", 142, 43),
text("m3", 172, 43),
text("돌쌓기", 102, 33),
text("2.5", 142, 33),
text("m2", 172, 33),
];
const ask = (type, want, extra = {}) =>
new Promise((resolve) => {
const on = (e) => {
if (e.source !== win || e.data?.type !== want) return;
window.removeEventListener("message", on);
resolve(e.data);
};
window.addEventListener("message", on);
win.postMessage({ type, ...extra }, location.origin);
setTimeout(() => resolve(null), 5000);
});
const original = (await ask("aislo:b08:save-request", "aislo:b08:save-response"))?.drawing;
const load = (drawing) =>
ask("aislo:b08:load-drawing", "aislo:b08:drawing-loaded", {
drawing,
readOnly: true,
panelsCollapsed: true,
recoveryScope: "verify83",
});
await load({
format: 6,
entities: grid,
layers: [{ id: L, name: "검증83", isVisible: true, isLocked: false }],
});
await W(400);
const canvas = frame.contentDocument.querySelector("canvas");
const fire = (type, p) => {
const r = canvas.getBoundingClientRect();
canvas.dispatchEvent(
new win.MouseEvent(type, {
bubbles: true,
clientX: r.left + p.x,
clientY: r.bottom - p.y,
button: 0,
buttons: type === "mouseup" ? 0 : 1,
}),
);
};
const a = cad.screen(95, 25),
b = cad.screen(205, 65);
fire("mousemove", a);
await W(60);
fire("mousedown", a);
await W(60);
fire("mousemove", b);
await W(60);
fire("mouseup", b);
await W(250);
const ss = $(".m02-main__host .ss");
const state = () => {
const bar = $(".ss-tabs", ss);
return {
tabsShown: !!bar && !bar.hidden,
tabs: bar ? $$(".ss-tabs__tab", bar).map((t) => t.innerText.trim()) : [],
active: $(".ss-tabs__tab.is-active", ss)?.innerText.trim() ?? null,
sheets: bar ? $$(".ss-tabs__tab", bar).length : 1,
cells: $$(".aislo-cell__text", ss)
.map((c) => c.innerText.trim())
.filter(Boolean)
.slice(0, 12),
widths: $$(".aislo-grid__colhead", ss)
.slice(0, 3)
.map((c) => Math.round(c.getBoundingClientRect().width)),
notabsClass: ss.classList.contains("ss--notabs"),
};
};
const out = { selected: cad.selection().length, before: state() };
btn.click();
await W(800);
out.added = state();
const undo = $$("button", ss).find((x) => /되돌리기|실행 취소|↶/.test(x.title + x.innerText));
undo?.click();
await W(500);
out.undone = state();
btn.click();
await W(800);
out.again = state();
out.restored = !!(await load(original));
return JSON.stringify(out);
})();