feat(534-1): 내장 엑셀 그림 겹판 · 쪽 나눔 점선 — 늦게 불러옴 · 행열 넣기 · 스크롤 따라감

- spreadsheet_figures: 시트 그림을 닻 범위에 얹음(figureSrc 없으면 이름 상자) · 쪽 나눔 행 아래 점선 · overlay 맨 아래 층
- spreadsheet: figureSrc 선택 · 그림 · 쪽 나눔 있는 문서일 때만 import()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 19:40:31 +09:00
co-authored by Claude Opus 5.5
parent f20aea412a
commit fbd9721931
2 changed files with 121 additions and 0 deletions
+16
View File
@@ -24,6 +24,7 @@ import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import type { ConditionOptions } from "./spreadsheet_condition";
import type { FigureSrc } from "./spreadsheet_figures";
import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu";
@@ -69,6 +70,8 @@ export interface SpreadsheetOptions {
preview?: boolean;
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
conditions?: ConditionOptions;
/** 그림 열쇠 → 주소(요구 347-5 · 시트 `그림`) — 없으면 이름 적힌 빈 상자 */
figureSrc?: FigureSrc;
}
export interface SpreadsheetHandle {
@@ -185,9 +188,21 @@ export function createSpreadsheet(
});
}
/** 그림 겹판 · 쪽 나눔 점선 — 문서에 그림 · 쪽 나눔이 있을 때만 불러옴(크기 예산) */
let figuring = false;
function loadFigures(): void {
const any = ctx.book.시트.some((s) => s.그림?.length || s.인쇄?.쪽나눔?.length);
if (figuring || !any) return;
figuring = true;
void import("./spreadsheet_figures").then((m) => {
if (root.isConnected) others.push(m.attachFigures(ctx, opts.figureSrc));
});
}
grid.render();
select(ctx.selection, false, true);
loadLinks();
loadFigures();
if (opts.conditions && !readOnly)
void import("./spreadsheet_condition").then((m) => {
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
@@ -292,6 +307,7 @@ export function createSpreadsheet(
grid.render();
select(ctx.selection, false, true);
loadLinks();
loadFigures();
},
recalc() {
ctx.engine.rebuild(ctx.book);
@@ -0,0 +1,105 @@
/* =============================================================================
* spreadsheet_figures.ts (주인 P · 534 sub6)
* 그림 겹판 · 쪽 나눔 점선(요구 347-5) — 시트 `그림`(범위 닻)을 칸 위에 얹고 `인쇄.쪽나눔` 행 아래에 점선.
* 격자 overlay 맨 아래 층(고름 테두리가 위) · 누름은 칸으로 통과 · 스크롤 · 확대 · 다시 그림마다 자리를 다시 셈.
* 그림 주소 = 부른 쪽 `figureSrc(열쇠)` · 없으면 이름 적힌 빈 상자. 그림 옮기기 · 나눔 고치기 화면은 없음(1차).
* `createSpreadsheet` 가 그림 · 쪽 나눔이 있는 문서일 때만 불러옴(크기 예산).
* ========================================================================== */
import { parseRange } from "./spreadsheet_address";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
export type FigureSrc = (열쇠: string) => string | undefined;
const css = (node: HTMLElement, style: Partial<CSSStyleDeclaration>) =>
Object.assign(node.style, style);
export function attachFigures(ctx: SpreadsheetContext, figureSrc?: FigureSrc): PartHandle {
const layer = document.createElement("div");
layer.className = "ss-figures";
css(layer, { position: "absolute", inset: "0", overflow: "hidden", pointerEvents: "none" });
const imgs = new Map<string, HTMLImageElement>(); // 열쇠 → 그림(다시 그려도 다시 안 받음)
function draw(): void {
const overlay = ctx.grid.root.querySelector<HTMLElement>(".aislo-grid__overlay");
if (!overlay) return;
if (overlay.firstChild !== layer) overlay.prepend(layer);
// editorSlot 상자 = 격자 뿌리 기준 → overlay 기준(머리 칸만큼 밀림)
const box = (r: number, c: number) => {
const b = ctx.grid.editorSlot(r, c).box;
return { ...b, x: b.x - overlay.offsetLeft, y: b.y - overlay.offsetTop };
};
const sheet = ctx.sheet();
const parts: HTMLElement[] = [];
for (const f of sheet.그림 ?? []) {
const rg = parseRange(f.닻);
if (!rg) continue;
const a = box(rg.r0, rg.c0);
const b = box(rg.r1, rg.c1);
const frame = document.createElement("div");
frame.className = "ss-figure";
frame.dataset.key = f.열쇠;
css(frame, {
position: "absolute",
left: `${a.x}px`,
top: `${a.y}px`,
width: `${Math.max(0, b.x + b.w - a.x)}px`,
height: `${Math.max(0, b.y + b.h - a.y)}px`,
boxSizing: "border-box",
background: "#fff",
});
const src = figureSrc?.(f.열쇠);
if (src) {
let img = imgs.get(f.열쇠);
if (!img || img.getAttribute("src") !== src) {
img = document.createElement("img");
img.src = src;
img.alt = f.이름 ?? f.열쇠;
css(img, { width: "100%", height: "100%", objectFit: "contain", display: "block" });
imgs.set(f.열쇠, img);
}
frame.append(img);
} else {
css(frame, {
border: "1px dashed #888",
color: "#666",
font: "12px sans-serif",
padding: "4px",
});
frame.textContent = f.이름 ?? f.열쇠;
}
parts.push(frame);
}
const view = ctx.grid.visibleRange();
for (const k of sheet.인쇄?.쪽나눔 ?? []) {
const at = box(k - 1, view.c0);
const line = document.createElement("div");
line.className = "ss-pagebreak";
line.dataset.row = String(k);
css(line, {
position: "absolute",
left: "0",
right: "0",
top: `${at.y + at.h - 1}px`,
borderTop: "2px dashed #3b6fd8",
});
parts.push(line);
}
layer.replaceChildren(...parts);
}
const onScroll = () => draw();
ctx.grid.root.addEventListener("scroll", onScroll, true);
ctx.grid.root.addEventListener("aislo-grid-zoom", onScroll);
draw();
return {
root: null,
refresh: draw,
destroy() {
ctx.grid.root.removeEventListener("scroll", onScroll, true);
ctx.grid.root.removeEventListener("aislo-grid-zoom", onScroll);
layer.remove();
},
};
}