Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-10 16:40:43 +09:00
24 changed files with 1178 additions and 93 deletions
@@ -1,5 +1,5 @@
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
import { getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
import { drawEntities } from './draw-functions';
import { getSceneVersion } from './scene-version';
import { queryEntitiesInBox } from './spatial-index';
@@ -78,7 +78,8 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void {
const savedHighlight = getHighlightedEntityIds();
if (savedHighlight.length) setHighlightedEntityIds([]);
drawController.withContext(offscreenContext, () => {
drawController.clear();
// 투명 바탕 — 배경 · 모눈은 화면 clear() 가 그림(모눈이 비트맵에 박히면 매 프레임 다시 그려야 함)
offscreenContext.clearRect(0, 0, size.x, size.y);
// Style-run batching + sub-pixel decimation: one stroke per style run
drawController.beginBatch();
drawEntities(drawController, sceneEntitiesForViewport(drawController));
@@ -124,13 +125,7 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe
const offsetChanged =
!!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y);
// Grid lines are screen-fixed (drawn in clear()), so blitting a shifted
// bitmap would drag the grid along — always re-render while grid is on.
if (
paramsChanged ||
getGridEnabled() ||
(offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)
) {
if (paramsChanged || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
rebuildScene(drawController);
}
@@ -26,8 +26,12 @@ let gridMinX = 0;
let gridMinY = 0;
let gridCols = 1;
/** 칸 번호 범위 — 엔티티가 놓인 칸(0~GRID_CELLS_PER_AXIS) 밖은 비었으니 잘라 냄(줌아웃 때 빈 칸 수억 개를 돌지 않게). */
function cellRange(min: number, max: number, gridMin: number): [number, number] {
return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)];
return [
Math.max(0, Math.floor((min - gridMin) / cellSize)),
Math.min(GRID_CELLS_PER_AXIS, Math.floor((max - gridMin) / cellSize)),
];
}
function ensureIndex(): void {
@@ -0,0 +1,191 @@
import { Point } from '@flatten-js/core';
import { beforeAll, describe, expect, it, vi } from 'vitest';
/** 줌아웃 느려짐(227-1) — 가짜 캔버스로 한 화면 그리기 비용을 잰다. */
const counts = vi.hoisted(() => {
const tally = { segments: 0, arcs: 0, strokes: 0, fills: 0, texts: 0 };
const g = globalThis as Record<string, unknown>;
g.window = { dispatchEvent: () => true, matchMedia: () => ({ matches: true }) };
g.Path2D = class {
moveTo() {}
lineTo() {
tally.segments++;
}
arc() {
tally.arcs++;
}
};
const noop = () => {};
g.__fakeContext = {
fillRect: noop,
clearRect: noop,
setLineDash: noop,
beginPath: noop,
moveTo: noop,
lineTo: () => tally.segments++,
arc: () => tally.arcs++,
closePath: noop,
stroke: () => tally.strokes++,
fill: () => tally.fills++,
save: noop,
restore: noop,
translate: noop,
rotate: noop,
fillText: () => tally.texts++,
measureText: (label: string) => ({ width: label.length * 7 }),
drawImage: noop,
};
return tally;
});
import type { Layer } from '../App.types';
import { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import type { Entity } from '../entities/Entity';
import { HatchEntity } from '../entities/HatchEntity';
import { LineEntity } from '../entities/LineEntity';
import { MeasurementEntity } from '../entities/MeasurementEntity';
import { PolyLineEntity } from '../entities/PolyLineEntity';
import { TextEntity } from '../entities/TextEntity';
import { setEntities, setGridEnabled, setLayers } from '../state';
import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache';
import { queryEntitiesInBox } from './spatial-index';
const LAYER = 'perf';
function polyline(points: Point[]): PolyLineEntity {
const segments = points
.slice(1)
.map((point, index) => new LineEntity(LAYER, points[index], point));
return new PolyLineEntity(LAYER, segments);
}
/** B07 횡단 장 꼴 — 횡단 40 개(지반선 · 계획선 · 절토 · 성토 해치 · 글자 · 치수 · 모눈) + 등고선 */
function crossSectionSheet(): Entity[] {
const entities: Entity[] = [];
for (let index = 0; index < 40; index++) {
const ox = (index % 4) * 80;
const oy = Math.floor(index / 4) * 30;
const ground = Array.from(
{ length: 61 },
(_, k) => new Point(ox + k, oy + 10 + 3 * Math.sin(k / 7 + index))
);
entities.push(polyline(ground));
const design = [0, 15, 20, 25, 40, 45, 60].map(
(x, k) => new Point(ox + x, oy + 9 + (k % 2 ? 1 : 0))
);
entities.push(polyline(design));
const hatchOptions = { style: 'pattern', spacing: 0.3 } as const;
entities.push(
new HatchEntity(
LAYER,
[new Point(ox + 15, oy + 9), new Point(ox + 25, oy + 9), new Point(ox + 20, oy + 13)],
hatchOptions
),
new HatchEntity(
LAYER,
[new Point(ox + 40, oy + 9), new Point(ox + 60, oy + 9), new Point(ox + 50, oy + 6)],
hatchOptions
)
);
for (let k = 0; k < 25; k++) {
entities.push(
new TextEntity(LAYER, `${index}+${k}.00`, new Point(ox + 2 * k, oy + 2), { fontSize: 0.5 })
);
}
for (let k = 0; k < 6; k++) {
entities.push(
new MeasurementEntity(
LAYER,
new Point(ox + 10 * k, oy + 8),
new Point(ox + 10 * k + 8, oy + 8),
new Point(ox + 10 * k, oy + 7)
)
);
}
for (let k = 0; k <= 12; k++) {
entities.push(
new LineEntity(LAYER, new Point(ox + 5 * k, oy), new Point(ox + 5 * k, oy + 20))
);
}
}
for (let c = 0; c < 178; c++) {
const contour = Array.from(
{ length: 85 },
(_, k) => new Point(k * 3.7, c * 1.7 + 2 * Math.sin(k / 5 + c))
);
entities.push(polyline(contour));
}
return entities;
}
function viewAt(controller: ScreenCanvasDrawController, factor: number, fitScale: number) {
const size = controller.getCanvasSize();
const center = controller.targetToWorld(new Point(size.x / 2, size.y / 2));
const scale = fitScale * factor;
controller.setScreenScale(scale);
controller.setScreenOffset(
new Point(center.x - size.x / 2 / scale, center.y - size.y / 2 / scale)
);
}
describe('줌아웃 그리기 비용(227-1)', () => {
const controller = new ScreenCanvasDrawController(
(globalThis as unknown as { __fakeContext: CanvasRenderingContext2D }).__fakeContext
);
let fitScale = 1;
beforeAll(() => {
const g = globalThis as Record<string, unknown>;
g.document = {
documentElement: { hasAttribute: () => false, getAttribute: () => null },
createElement: () => ({ width: 0, height: 0, getContext: () => g.__fakeContext }),
};
g.getComputedStyle = () => ({ getPropertyValue: () => '' });
setLayers(
[{ id: LAYER, name: LAYER, isVisible: true, isLocked: false } as unknown as Layer],
false
);
setEntities(crossSectionSheet(), false);
controller.setCanvasSize(new Point(1600, 900));
controller.zoomToFitScreen();
fitScale = controller.getScreenScale();
});
it('배율별 한 화면 다시 그리기 — 시간 · 그린 수(보고용 표)', () => {
const rows = [];
for (const factor of [10, 1, 0.1, 0.01]) {
viewAt(controller, factor, fitScale);
for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0;
invalidateSceneCache();
const startedAt = performance.now();
drawScene(controller, 0);
const ms = performance.now() - startedAt;
rows.push({ factor, ms: Number(ms.toFixed(1)), ...counts });
}
console.table(rows);
expect(rows).toHaveLength(4);
});
it('화면보다 아주 큰 영역 조회도 빈 칸을 돌지 않음 — 0.001배 상자 50ms 안', () => {
viewAt(controller, 1, fitScale);
const all = queryEntitiesInBox(-1e9, -1e9, 1e9, 1e9).length;
const size = controller.getCanvasSize();
const half = (Math.max(size.x, size.y) / (fitScale * 0.001)) * 1.5;
const startedAt = performance.now();
const found = queryEntitiesInBox(-half, -half, half, half);
const ms = performance.now() - startedAt;
expect(found.length).toBe(all);
expect(ms).toBeLessThan(50);
});
it('모눈을 켜도 화면이 그대로면 장면을 다시 그리지 않음', () => {
viewAt(controller, 1, fitScale);
setGridEnabled(true);
drawScene(controller, 0);
const rebuilds = scenePerf.sceneRebuilds;
drawScene(controller, 1000);
drawScene(controller, 2000);
setGridEnabled(false);
expect(scenePerf.sceneRebuilds).toBe(rebuilds);
});
});
@@ -0,0 +1,89 @@
import { Point } from '@flatten-js/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { createActor } from 'xstate';
import { LineEntity } from '../entities/LineEntity.ts';
import {
getEntities,
resetUndoBaseline,
setEntities,
setSelectedEntityIds,
undo,
} from '../state.ts';
import { ScaleState, scaleToolStateMachine } from './scale-tool.ts';
import { ActorEvent } from './tool.types.ts';
const ends = (index: number) => {
const line = getEntities()[index] as LineEntity;
const a = line.getStartPoint();
const b = line.getEndPoint();
return [a.x, a.y, b.x, b.y];
};
/** 기준점 (2,0) 클릭까지 — 선 A(0,0)-(10,0) 고름 · 선 B(0,5)-(4,5) 는 그대로 둘 것 */
function startAtBasePoint() {
const a = new LineEntity('layer1', new Point(0, 0), new Point(10, 0));
const b = new LineEntity('layer1', new Point(0, 5), new Point(4, 5));
setEntities([a, b]);
resetUndoBaseline();
setSelectedEntityIds([a.id]);
const actor = createActor(scaleToolStateMachine).start();
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT);
actor.send({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation: new Point(2, 0),
screenMouseLocation: new Point(0, 0),
holdingCtrl: false,
holdingShift: false,
});
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT);
return { actor, a, b };
}
const typeNumber = (actor: ReturnType<typeof startAtBasePoint>['actor'], value: number) =>
actor.send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: new Point(99, 99) });
describe('축척 — 기준점 + 숫자 배율 (226-1)', () => {
beforeEach(() => setSelectedEntityIds([]));
it('배율 2 = 기준점 고정 두 배 · 다른 객체 · 순서 그대로', () => {
const { actor, a, b } = startAtBasePoint();
typeNumber(actor, 2);
// (0,0)→(-2,0) · (10,0)→(18,0) — 기준점 (2,0) 은 그대로
expect(ends(0)).toEqual([-2, 0, 18, 0]);
expect(getEntities()[0].id).toBe(a.id);
expect(getEntities()[1]).toBe(b);
expect(ends(1)).toEqual([0, 5, 4, 5]);
// 끝나면 선택 풀고 다음 고르기로
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_SELECTION);
});
it('배율 0.5 = 절반', () => {
const { actor } = startAtBasePoint();
typeNumber(actor, 0.5);
expect(ends(0)).toEqual([1, 0, 6, 0]);
});
it('되돌리기 한 번에 원래 자리', () => {
const { actor } = startAtBasePoint();
typeNumber(actor, 2);
undo();
expect(ends(0)).toEqual([0, 0, 10, 0]);
expect(getEntities()).toHaveLength(2);
});
it('Esc = 취소 — 객체 그대로', () => {
const { actor, a, b } = startAtBasePoint();
actor.send({ type: ActorEvent.ESC });
expect(getEntities()).toEqual([a, b]);
expect(ends(0)).toEqual([0, 0, 10, 0]);
typeNumber(actor, 2);
expect(ends(0)).toEqual([0, 0, 10, 0]);
});
it('배율 0 은 받지 않음', () => {
const { actor } = startAtBasePoint();
typeNumber(actor, 0);
expect(ends(0)).toEqual([0, 0, 10, 0]);
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT);
});
});
@@ -2,15 +2,23 @@ import type { Point } from '@flatten-js/core';
import {
addEntities,
deleteEntities,
getEntities,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setEntities,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import type {
DrawEvent,
MouseClickEvent,
NumberInputEvent,
StateEvent,
ToolContext,
} from './tool.types';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
@@ -42,6 +50,7 @@ export enum ScaleAction {
COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE',
DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES',
SCALE_SELECTION = 'SCALE_SELECTION',
SCALE_SELECTION_BY_FACTOR = 'SCALE_SELECTION_BY_FACTOR',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
}
@@ -98,7 +107,7 @@ export const scaleToolStateMachine = createMachine(
[ScaleState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to scale',
meta: {
instructions: 'Select what you want to scale, then ENTER',
instructions: '축척할 객체를 선택한 뒤 ENTER.',
},
invoke: {
id: 'selectToolInsideTheScaleTool',
@@ -142,7 +151,7 @@ export const scaleToolStateMachine = createMachine(
[ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: {
description: 'Select the origin of the scale operation',
meta: {
instructions: 'Select the origin of the scale operation',
instructions: '기준점을 지정하십시오. (Esc 취소)',
},
always: {
actions: ScaleAction.ENABLE_HELPERS,
@@ -161,9 +170,17 @@ export const scaleToolStateMachine = createMachine(
[ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: {
description: 'Select the end of the base scale line',
meta: {
instructions: 'Select the end of the base scale line',
instructions:
'배율을 입력하고 ENTER — 또는 끌어서 맞출 기준선 끝을 클릭하십시오. (Esc 취소)',
},
on: {
// 오토캐드 SCALE — 기준점 뒤 숫자 배율(2 = 두 배 · 0.5 = 절반)
NUMBER_INPUT: {
guard: ({ event }) =>
(event as NumberInputEvent).value > 0 && getSelectedEntityIds().length > 0,
actions: [ScaleAction.SCALE_SELECTION_BY_FACTOR, ScaleAction.DESELECT_ENTITIES],
target: ScaleState.INIT,
},
MOUSE_CLICK: {
actions: [
ScaleAction.RECORD_BASE_VECTOR_END_POINT,
@@ -180,7 +197,7 @@ export const scaleToolStateMachine = createMachine(
[ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: {
description: 'Select the end of the scale line',
meta: {
instructions: 'Select the end of the scale line',
instructions: '새 길이의 끝을 클릭하십시오. (Esc 취소)',
},
on: {
DRAW: {
@@ -235,7 +252,9 @@ export const scaleToolStateMachine = createMachine(
return {
...context,
// Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
originalSelectedEntities: compact(
selectedEntities.map((entity) => cloneKeepingLook(entity, true))
),
};
}),
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
@@ -282,6 +301,24 @@ export const scaleToolStateMachine = createMachine(
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[ScaleAction.SCALE_SELECTION_BY_FACTOR]: ({ context, event }) => {
const origin = context.baseVectorStartPoint;
if (!origin) {
throw new Error('[SCALE] Calling scale by factor without a base point');
}
const factor = (event as NumberInputEvent).value;
const selectedIds = new Set(getSelectedEntityIds());
// 제자리 교체 — 복사본을 키워 같은 순서에 넣고 되돌리기 한 칸으로 남긴다
setEntities(
getEntities().map((entity) => {
if (!selectedIds.has(entity.id)) return entity;
const scaled = cloneKeepingLook(entity, true);
scaled.scale(origin, factor);
return scaled;
}),
true
);
},
[ScaleAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
@@ -32,7 +32,7 @@ const GUIDE_MORE =
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
"끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " +
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
"묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭";
"묶음 줄로 바꾸기 · 추가 · 지우기 = 줄 우클릭";
let picked: string | null = null;
/** 지금 그린 도구 줄 — 키가 부름 */
@@ -243,6 +243,13 @@ export interface StructureNumbers {
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
scoped("/structures/numbers");
/** 같은 번호 목록을 **고른 층**(+ 주인)에서 — 지금 층 길을 안 바꾸고 읽음(고르기 모달 상단 표 · 224-1) */
export const fetchStructureNumbersAt = (
layer: Layer,
owner: string | null,
): Promise<Record<string, StructureNumbers>> =>
call(owned(layer, `/layers/${layer}/structures/numbers`, owner));
/** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */
export interface StructureCombo extends StructureNumbers {
키: string;
@@ -86,6 +86,17 @@ export function laborSumState(
return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined;
}
/** 구조물 조합 표 번호 · 합계 줄 글(86-3 · 101-1) — 관리 화면과 고르기 모달 상단 표(224-1)가 같은 한 벌 */
export function numberLabels(numbers: () => ColumnNumbers) {
return {
laborLabel: (colId: string) => numbers()[colId]?.일위대가번 ?? "",
drawingLabel: (colId: string) => numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => numbers()[colId]?.면적번 ?? "",
laborSumLabel: (colId: string) => laborSumText(numbers()[colId]),
laborSumState: (colId: string) => laborSumState(numbers()[colId]),
};
}
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
export interface StructureColumn {
id: string;
@@ -22,8 +22,7 @@ import {
comboMerge,
comboView,
createStructurePanel,
laborSumState,
laborSumText,
numberLabels,
pillsFor,
STRUCTURE_TABLE,
type StructureColumn,
@@ -381,11 +380,7 @@ export function createMain(
onChange: () => (dirty = !readOnly),
...(struct
? {
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1
...numberLabels(struct.numbers), // 번호 · 일위대가 합계 줄(86-3 · 101-1) — 고르기 모달 상단 표와 한 벌
combo: {
...(readOnly || !extras.onHeadRename
? {}
@@ -120,3 +120,4 @@
94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가
95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문)
96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선
97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선
+162 -5
View File
@@ -1,4 +1,4 @@
/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율) 단위검증 헬퍼 —
/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기 · 221-2 스크롤) 단위검증 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */
const fs = require("fs");
const path = require("path");
@@ -37,6 +37,8 @@ function fakeWindow(doc) {
(win.calls = win.calls.filter((c) => c.type !== type || c.fn !== fn));
win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq);
win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id));
win.requestAnimationFrame = (fn) => win.setTimeout(fn, 16); // 한 화면 틀
win.cancelAnimationFrame = win.clearTimeout;
win.tick = (ms) => {
win.now += ms;
for (const t of win.timers.filter((t) => t.at <= win.now)) {
@@ -271,6 +273,98 @@ r.origin = {
bottomRight: m.loupeOrigin(995, 795, 1000, 800),
};
{
// 221-1 CAD 영역 — 위에선 안 켬 · 켜진 채 들어가면 숨김 · 나오면 보임 · 휠은 CAD 로
const cad = { tagName: "IFRAME", closest: (sel) => (sel === m.LOUPE_SKIP ? {} : null) };
const plain = { tagName: "DIV", closest: () => null };
const kinds = (v) => v.log.map((l) => l[0]);
r.cad = {
isCad: [m.isCadTarget(cad), m.isCadTarget(plain), m.isCadTarget(null), m.isCadTarget({})],
skip: m.LOUPE_SKIP,
};
{
// Z 를 CAD 위에서 누름 — 안 켬 · 나오면 보임 · 떼면 끔
const { win, view } = setup();
win.fire("mouseover", { target: cad });
win.fire("keydown", { code: "KeyZ", target: cad });
const onCad = view.log.length;
const wheel = win.fire("wheel", { deltaY: -100, target: cad });
win.fire("mouseover", { target: plain, clientX: 50, clientY: 60 });
const out = view.log[0];
win.fire("keyup", { code: "KeyZ" });
r.cad.keyOnCad = { onCad, wheelPrevented: wheel.prevented, out, off: last(view) };
}
{
// 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임
const { win, view } = setup();
win.fire("keydown", { code: "KeyZ" });
win.fire("wheel", { deltaY: -100 }); // 2.25
win.fire("mouseover", { target: cad });
const hidden = last(view);
const n = view.log.length;
const wheel = win.fire("wheel", { deltaY: -100, target: cad });
win.fire("mousemove", { target: cad, clientX: 5, clientY: 5 });
const quiet = view.log.length === n; // 숨긴 동안 move · 배율 안 부름
win.fire("mouseover", { target: plain, clientX: 70, clientY: 80 });
const back = view.log.slice(n);
win.fire("mousemove", { target: cad, clientX: 6, clientY: 6 });
const moveHide = last(view);
win.fire("mousemove", { target: plain, clientX: 7, clientY: 7 });
r.cad.enter = {
hidden,
wheel: { prevented: wheel.prevented, stopped: wheel.stopped },
quiet,
back,
moveHide,
moveBack: kinds(view).slice(-3),
};
}
{
// 우클릭 길게 — CAD 위면 안 켜고 메뉴 그대로
const { win, view } = setup();
win.fire("mousedown", { button: 2, buttons: 2, target: cad });
win.tick(1000);
win.fire("mouseup", { button: 2, target: cad });
const menu = win.fire("contextmenu", { target: cad });
r.cad.press = { log: view.log.length, menuPrevented: menu.prevented };
}
{
// 초점이 CAD iframe 으로 넘어감(cad_host) — 끄지 않고 숨김 · Z 떼기는 그쪽 창에서
const { win, view } = setup();
const frameCalls = [];
const frame = {
...cad,
contentWindow: { addEventListener: (type, fn, cap) => frameCalls.push({ type, fn, cap }) },
};
win.document = { activeElement: frame };
win.fire("keydown", { code: "KeyZ" });
win.fire("blur", { target: win });
const blurHidden = kinds(view);
win.document = { activeElement: plain };
const up = frameCalls.find((c) => c.type === "keyup");
up.fn({ code: "KeyZ" });
win.fire("mouseover", { target: plain });
const afterRelease = kinds(view);
// 우클릭 누른 채 들어감 — 나오면 다시 보임
const b = setup();
b.win.fire("mousedown", { button: 2, buttons: 2 });
b.win.tick(400);
b.win.document = { activeElement: frame };
b.win.fire("blur", { target: b.win });
b.win.document = { activeElement: plain };
b.win.fire("mousemove", { buttons: 2, target: plain });
const pressBack = kinds(b.view);
b.win.fire("mouseup", { button: 2 });
r.cad.blur = {
blurHidden,
frameKeyup: up && up.cap,
afterRelease,
pressBack,
pressOff: last(b.view),
};
}
}
// ---- 가짜 요소 ----
const SVG_NS = "http://www.w3.org/2000/svg";
class El {
@@ -282,7 +376,7 @@ class El {
this.style = {};
this.dataset = {};
this.attrs = {};
this.scrollTop = opts.scrollTop || 0;
this.st = opts.scrollTop || 0;
this.scrollLeft = 0;
this.width = opts.width || 0;
this.height = opts.height || 0;
@@ -299,6 +393,17 @@ class El {
getBoundingClientRect() {
return this.rect;
}
// 실제 DOM 처럼 숨긴(hidden) 조상 아래에선 스크롤을 적어도 안 먹음
get scrollTop() {
return this.st;
}
set scrollTop(v) {
for (let e = this; e; e = e.parent) if (e.hidden) return;
this.st = v;
}
get classList() {
return { contains: (k) => this.className.split(" ").includes(k) };
}
setAttribute(k, v) {
this.attrs[k] = v;
}
@@ -370,8 +475,15 @@ function makePage() {
contentDocument: frameDoc,
rect: { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 },
});
const cadBox = new El("DIV", {
rect: { left: 0, top: 300, right: 400, bottom: 600, width: 400, height: 300 },
});
cadBox.className = "cad-host";
cadBox.append(new El("IFRAME"), new El("A"));
scroller.scrollHeight = 900; // 스크롤될 수 있는 칸
scroller.clientHeight = 300;
scroller.append(canvas, blank, input, check);
app.append(scroller, offscreen, table, svg, frame);
app.append(scroller, offscreen, table, svg, frame, cadBox);
return app;
}
function fakeDocument(app) {
@@ -406,7 +518,7 @@ function fakeDocument(app) {
const stage = root.children[0];
const page = stage.children[0];
const copy = page.children[0];
const [scroller, offscreen, table, svg, frame] = copy.children;
const [scroller, offscreen, table, svg, frame, cadBox] = copy.children;
const [canvas, blank, input, check] = scroller.children;
r.capture = {
rootClass: root.className,
@@ -429,7 +541,10 @@ function fakeDocument(app) {
svgKept: svg.children.length,
frameTag: frame.tagName,
frameDrawn: frame.drawn,
frameSize: [frame.width, frame.height],
frameSize: [frame.style.width, frame.style.height],
frameEmpty: frame.children.length,
cadBoxEmpty: cadBox.children.length,
cadBoxSize: [cadBox.style.width, cadBox.style.height],
watching: observers[0].watching === app,
observeOpts: observers[0].opts,
scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture),
@@ -478,4 +593,46 @@ function fakeDocument(app) {
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
r.hide.reuseRoot = doc.body.children.length;
}
{
// 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서)
const app = makePage();
const scroller = app.children[0];
const doc = fakeDocument(app);
const win = fakeWindow(doc);
win.MutationObserver = class {
observe() {}
disconnect() {}
};
const view = m.createLoupeView(win);
const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0];
const page = () => doc.body.children[0].children[0].children[0];
view.show(500, 400);
view.hide();
scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘
view.show(500, 400);
const zero = cloneScroller().scrollTop;
view.hide();
scroller.scrollTop = 260; // 꺼진 동안 스크롤해 둠 → 다시 켬(숨긴 원을 다시 씀)
view.show(500, 400);
const before = cloneScroller().scrollTop;
// 켠 채 스크롤 — 다음 화면 틀에 위치만 · 같은 복제
const first = cloneScroller();
scroller.scrollTop = 400;
app.rect = { ...app.rect, top: -50, bottom: 750 }; // 문서도 50 내려감
const scroll = () => win.calls.filter((c) => c.type === "scroll").forEach((c) => c.fn({}));
scroll();
scroll(); // 한 틀에 한 번으로 묶음
const rafs = win.timers.filter((t) => t.at === win.now + 16).length;
win.tick(16);
const during = {
top: cloneScroller().scrollTop,
same: cloneScroller() === first,
pageTop: page().style.top,
};
win.tick(484); // 0.5 초 뒤 다시 뜸 — 비워 둔 칸을 채움 · 스크롤은 그대로
const recaptured = { same: cloneScroller() === first, top: cloneScroller().scrollTop };
view.hide();
const n = win.timers.length;
r.scroll = { zero, before, rafs, during, recaptured, timersAfterHide: n };
}
console.log(JSON.stringify(r));
@@ -0,0 +1,167 @@
/* 고르기 모달 상단 표(224-1) 시험 헬퍼 — `ui_template_item_picker_table.ts` 를 트랜스파일해
* 표 부품 · 집계표 모듈 · 서버 읽기를 대역으로 갈아 끼우고 돌려 부름 기록을 JSON 한 벌로 냄.
* 동적 `import()` 도 CommonJS 변환에서 같은 `require` 길을 타 대역이 먹음.
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const UI = path.join(ROOT, "ui_template");
const log = [];
const state = { numbersFail: false, tableFail: null };
const node = (props = {}) => ({
className: props.className ?? "",
hidden: "attrs" in props && props.attrs && "hidden" in props.attrs,
text: props.text ?? "",
children: props.children ?? [],
replaceChildren(...kids) {
this.children = kids;
},
});
const STUBS = {
"./ui_template_elements": { el: (_tag, props) => node(props) },
"./ui_template_locale": { t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중") },
"../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch": {
readTemplate: async (...args) => {
log.push(["readTemplate", ...args]);
if (state.tableFail) throw new Error(state.tableFail);
return { 문서: { 열: [], tag: `${args[0]}|${args[4]}` } };
},
fetchStructureNumbersAt: async (...args) => {
log.push(["numbers", ...args]);
if (state.numbersFail) throw new Error("numbers down");
return { rv1: { 도번: "SD1" } };
},
},
"../M02_MasterTemplete/M02_MasterTemplete_Structure": {
STRUCTURE_TABLE: "구조물집계표",
comboView: (doc) => ({ ...doc, comboView: true }),
numberLabels: (numbers) => ({ laborLabel: () => "", seenNumbers: numbers() }),
},
"./sheet/ui_template_sheet": {
createSheet: (host, doc, opts) => {
const entry = { doc, opts, selected: [], destroyed: false, host };
log.push(["createSheet", entry]);
host.replaceChildren({ sheet: true });
return {
selectColumn: (id) => entry.selected.push(id),
destroy: () => (entry.destroyed = true),
};
},
},
};
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
const row = (key, 열, layer, owner) => ({ key, 열, layer, owner });
const names = () => log.map((entry) => entry[0]);
const creates = () => log.filter((entry) => entry[0] === "createSheet").map((entry) => entry[1]);
(async () => {
const { createComboTable } = loadTs(path.join(UI, "ui_template_item_picker_table.ts"));
const data = loadTs(path.join(UI, "ui_template_item_picker_data.ts"));
const out = {};
const mine = row("SS1", "rv1", "personal", "1/3");
const sameLayer = row("SS2", "rv2", "personal", "1/3");
const master = row("SS9", "rv9", "system", null);
const table = createComboTable();
out.startHidden = table.root.hidden;
table.show(null);
out.nullHidden = table.root.hidden;
out.nullCalls = names();
table.show(mine);
out.shownHidden = table.root.hidden;
await flush();
out.firstReads = log.filter((e) => e[0] !== "createSheet");
const first = creates()[0];
out.first = {
count: creates().length,
readOnly: first.opts.readOnly,
lockHead: first.opts.lockHead,
mode: first.opts.mode,
combo: !!first.opts.combo,
comboDoc: first.doc.comboView,
docTag: first.doc.tag,
numbersSeen: first.opts.seenNumbers,
selected: first.selected,
};
log.length = 0;
table.show(sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만
await flush();
out.sameLayer = { calls: names(), selected: first.selected, destroyed: first.destroyed };
log.length = 0;
table.show(master); // 다른 층 — 그 층 표로 바뀜
await flush();
out.otherLayer = {
reads: log.filter((e) => e[0] === "readTemplate"),
creates: creates().length,
oldDestroyed: first.destroyed,
selected: creates()[0].selected,
docTag: creates()[0].doc.tag,
};
log.length = 0;
table.show(null);
out.hideAgain = table.root.hidden;
// 읽는 사이에 다른 조합을 고름 — 마지막 것만 그려짐
log.length = 0;
table.show(mine);
table.show(master);
await flush();
out.race = { creates: creates().length, docTag: creates()[0]?.doc.tag };
// 번호 읽기 실패 — 표는 그려짐
log.length = 0;
state.numbersFail = true;
const lone = createComboTable();
lone.show(mine);
await flush();
out.numbersFail = { creates: creates().length, seen: creates()[0]?.opts.seenNumbers };
state.numbersFail = false;
// 표 읽기 실패 — 까닭 글 · 표 없음
log.length = 0;
state.tableFail = "그 층은 아직 만든 것이 없음";
const broken = createComboTable();
broken.show(mine);
await flush();
out.tableFail = { creates: creates().length, text: broken.root.children[0].text };
out.keys = {
mineSame: data.tableKey(mine) === data.tableKey(sameLayer),
layerDiffers: data.tableKey(mine) !== data.tableKey(master),
ownerDiffers: data.tableKey(mine) !== data.tableKey(row("SS1", "rv1", "personal", "1/4")),
mine: data.tableKey(mine),
};
console.log(JSON.stringify(out));
})().catch((error) => {
console.error(error);
process.exit(1);
});
+13 -3
View File
@@ -49,7 +49,16 @@ def test_크기_배율_상수(r):
def test_창_캡처_단계로_들음(r):
assert r["listeners"] == [
[t, True] for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup")
[t, True]
for t in (
"mousemove",
"mouseover",
"mousedown",
"mouseup",
"contextmenu",
"keydown",
"keyup",
)
] + [["wheel", {"capture": True, "passive": False}], ["blur", True]]
@@ -139,8 +148,9 @@ def test_화면_뜨기(r):
assert c["offscreenChildren"] == 0 and c["offscreenSize"] == ["100px", "100px"] # 보이는 영역만
assert c["rowKept"] == 1 # 표 줄은 칸 너비 때문에 그대로
assert c["svgKept"] == 1
assert c["frameTag"] == "CANVAS" and c["frameDrawn"] == [["cad", 5, 6, 300, 200]]
assert c["frameSize"] == [320, 240]
# iframe(CAD) 은 복제하지 않음 — 같은 크기 빈 상자(221-1)
assert c["frameTag"] == "DIV" and c["frameDrawn"] == [] and c["frameEmpty"] == 0
assert c["frameSize"] == ["320px", "240px"]
assert c["watching"] and c["observeOpts"]["subtree"] and c["scrollListen"]
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""돋보기 CAD 영역 빼기(계획서 221-1) 회귀검증 — CAD(cad_host 상자 · iframe) 위에선 안 켬 ·
켜진 채 들어가면 숨김 · 나오면 다시 보임(배율 그대로) · CAD 위 휠은 안 막음 · 복제에서 CAD 는 빈칸.
218-1 헬퍼(`helper_218_1_loupe.cjs`)의 CAD 장면을 판정함.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_218_1_loupe.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_CAD_판정(r):
c = r["cad"]
assert c["skip"] == ".cad-host, iframe"
assert c["isCad"] == [True, False, False, False]
def test_CAD_위_Z_안_켬_나오면_보임(r):
k = r["cad"]["keyOnCad"]
assert k["onCad"] == 0 and k["wheelPrevented"] is False
assert k["out"] == ["show", 50, 60] # 나온 자리에 뜸
assert k["off"] == "hide"
def test_CAD_위_우클릭_길게_안_켬_메뉴_그대로(r):
assert r["cad"]["press"] == {"log": 0, "menuPrevented": False}
def test_켜진_채_CAD_위_숨김_휠_안_막음_나오면_배율_그대로(r):
e = r["cad"]["enter"]
assert e["hidden"] == "hide"
assert e["wheel"] == {"prevented": False, "stopped": False} # CAD 휠 줌 그대로
assert e["quiet"] # 숨긴 동안 원을 안 움직임
assert e["back"] == [["show", 70, 80], ["zoom", 2.25]]
assert e["moveHide"] == "hide" and e["moveBack"] == ["show", "zoom", "move"]
def test_초점이_CAD_로_넘어가도_끄지_않고_숨김(r):
b = r["cad"]["blur"]
assert b["blurHidden"] == ["show", "hide"]
assert b["frameKeyup"] is True # Z 떼기는 CAD 창에서 들음
assert b["afterRelease"] == ["show", "hide"] # 뗀 뒤 나와도 안 뜸
assert b["pressBack"] == ["show", "hide", "show", "move"] # 우클릭 누른 채 나오면 다시
assert b["pressOff"] == "hide"
def test_복제에서_CAD_빈칸(r):
c = r["capture"]
assert c["frameTag"] == "DIV" and c["frameEmpty"] == 0
assert c["frameSize"] == ["320px", "240px"]
assert c["cadBoxEmpty"] == 0 and c["cadBoxSize"] == ["400px", "300px"]
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""돋보기 스크롤 따라감(계획서 221-2) 회귀검증 — 스크롤해 둔 뒤 켜도 원 = 지금 화면 ·
켠 채 스크롤하면 다음 화면 틀(rAF 한 번)에 위치만 맞춤 · 0.5 초 뒤 다시 뜸 · 끄면 예약 없음.
218-1 헬퍼(`helper_218_1_loupe.cjs`)의 스크롤 장면을 판정함(숨긴 원 안에선 스크롤이 안 먹는 실제 동작 흉내).
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def s():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_218_1_loupe.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)["scroll"]
def test_스크롤한_뒤_켬(s):
assert s["zero"] == 0
assert s["before"] == 260 # 다시 켤 때도 지금 스크롤 그대로
def test_켠_채_스크롤_위치만_맞춤(s):
assert s["rafs"] == 1 # 스크롤 두 번 → 한 틀에 한 번
assert s["during"] == {"top": 400, "same": True, "pageTop": "-50px"} # 칸 · 문서 스크롤
def test_켠_채_스크롤_0_5초_뒤_다시_뜸(s):
assert s["recaptured"] == {"same": False, "top": 400}
assert s["timersAfterHide"] == 0
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""PLAN 224-1 — B05/B06 구조물 조합 고르기 모달 맨 위 = M02 구조물조합 상단 표(구조물집계표).
① 행동(Node · 대역): 모달 상단 표 부품(`ui_template_item_picker_table.ts`)을 표 부품 · 집계표 모듈 ·
서버 읽기 대역 위에서 돌려 — 고른 항목의 층 표를 보기 전용으로 그리고, 같은 층 다른 조합 = 열만 옮김 ·
다른 층 = 표가 바뀜 · 읽는 사이 바뀌면 마지막 것만 · 번호 읽기 실패해도 표 · 표 읽기 실패 = 까닭 글.
② 같은 부품(소스): M02 관리 화면 · 모달이 같은 `createSheet` · 같은 줄 글 도우미(`numberLabels`) · 같은
숨김 열 규칙(`comboView`)을 씀 · 모달이 상단 표를 배선(고르면 보임 · 편집 화면에선 숨김).
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
UI = ROOT / "ui_template"
M02 = ROOT / "M02_MasterTemplete"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", str(HERE / "helper_224_1_picker_top_table.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_처음엔_숨김_고르면_보임_비우면_다시_숨김(out):
assert out["startHidden"] is True
assert out["nullHidden"] is True
assert out["nullCalls"] == [] # 고른 것 없으면 서버도 안 부름
assert out["shownHidden"] is False
assert out["hideAgain"] is True
def test_고른_항목_층의_집계표를_보기_전용으로(out):
# 그 층 · 그 주인의 구조물집계표 + 번호 목록
assert out["firstReads"] == [
["readTemplate", "personal", "table", "구조물집계표", None, "1/3"],
["numbers", "personal", "1/3"],
]
first = out["first"]
assert first["count"] == 1
assert first["readOnly"] is True # 보기 전용 — 고침은 M02 에서
assert first["lockHead"] is True
assert first["combo"] is True # M02 「구조물 조합」 상단 표 꼴
assert first["comboDoc"] is True # 숨김 열 규칙 = M02 와 같은 `comboView`
assert first["docTag"] == "personal|1/3"
assert first["numbersSeen"] == {"rv1": {"도번": "SD1"}} # 줄 글 도우미가 같은 번호 목록을 받음
def test_같은_층_다른_조합은_열만_옮김_다른_층은_표가_바뀜(out):
same = out["sameLayer"]
assert same["calls"] == [] # 서버 · 표 다시 안 그림
assert same["selected"] == ["rv1", "rv2"]
assert same["destroyed"] is False
other = out["otherLayer"]
assert other["reads"] == [["readTemplate", "system", "table", "구조물집계표", None, None]]
assert other["creates"] == 1
assert other["oldDestroyed"] is True # 옛 표를 거둠
assert other["selected"] == ["rv9"]
assert other["docTag"] == "system|null"
def test_읽는_사이_바뀌면_마지막_조합만(out):
assert out["race"] == {"creates": 1, "docTag": "system|null"}
def test_번호_읽기_실패해도_표는_그려지고_표_읽기_실패는_까닭_글(out):
assert out["numbersFail"] == {"creates": 1, "seen": {}}
assert out["tableFail"] == {"creates": 0, "text": "표 실패 그 층은 아직 만든 것이 없음"}
def test_표_키_같은_층_주인이면_같고_층_주인이_다르면_다름(out):
assert out["keys"] == {
"mineSame": True,
"layerDiffers": True,
"ownerDiffers": True,
"mine": "personal|1/3",
}
def test_모달과_M02_가_같은_표_부품_줄_글_도우미_숨김_열_규칙():
table = _text(UI / "ui_template_item_picker_table.ts")
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
# 같은 표 부품(관리 화면은 이 파일을 glob 으로 받음)
assert 'import("./sheet/ui_template_sheet")' in table
assert '"../ui_template/sheet/ui_template_sheet.ts"' in main
# 보기 전용 · 상단 표 꼴
assert "readOnly: true," in table and "combo: {}," in table
# 줄 글 도우미 한 벌 — 둘 다 같은 함수
assert "export function numberLabels(" in struct
assert "...struct.numberLabels(() => numbers)," in table
assert "...numberLabels(struct.numbers)" in main
# 숨김 열 규칙 한 벌
assert "struct.comboView(got.문서)" in table
assert "comboView(doc)" in main
# 서버 읽기 = 고른 층 · 주인 · 같은 집계표 이름
assert 'readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner)' in table
assert 'STRUCTURE_TABLE = "구조물집계표"' in struct
def test_모달_배선_고르면_보이고_편집_화면에서는_숨김():
ts = _text(UI / "ui_template_item_picker.ts")
assert "createComboTable()" in ts
assert "comboTable.show(chosen);" in ts # 고른 항목이 바뀔 때마다
assert "body.append(tableWrap, cols, editHost," in ts # 맨 위
assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때
assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때
assert "comboTable.destroy();" in ts # 닫을 때 표 거둠
def test_표_높이_제한과_숨김_css():
css = _text(UI / "ui_template_item_picker.css")
assert ".ui-item-picker__table .ui-sheet__scroll {" in css
assert "max-height: 26vh;" in css
assert ".ui-item-picker__table-wrap[hidden]" in css
def test_번호_읽기_층_길_추가_지금_층을_안_바꿈():
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
assert "export const fetchStructureNumbersAt = (" in api
assert "call(owned(layer, `/layers/${layer}/structures/numbers`, owner));" in api
+1 -1
View File
@@ -26,7 +26,7 @@ def test_틀_제목_줄_부제_상태():
assert "loading: () => showLoading(host)," in shell
assert "fail: (reason, retry) => host.replaceChildren(loadingError(reason, retry))," in shell
assert "setDirty: (dirty) => (dot.hidden = !dirty)," in shell
assert 'event.key.toLowerCase() !== "s"' in shell # 카드 안 Ctrl+S
assert 'isCtrlKey(event, "s")' in shell # 카드 안 Ctrl+S
def test_저장_단추_알림_한_벌():
+5 -2
View File
@@ -163,8 +163,11 @@ def test_일위대가_합계_줄_표_부품과_연결():
render = _text(SHEET / "ui_template_sheet_render.ts")
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main
# 줄 글은 도우미 한 벌(`numberLabels` · 고르기 모달 상단 표와 같음 · 224-1)
assert "...numberLabels(struct.numbers)" in main
helper = _text(M02 / "M02_MasterTemplete_Structure.ts")
assert "laborSumLabel: (colId: string) => laborSumText(numbers()[colId])" in helper
assert "laborSumState: (colId: string) => laborSumState(numbers()[colId])" in helper
assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움
assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text(
SHEET / "ui_template_sheet.css"
+15
View File
@@ -52,6 +52,21 @@
overflow: auto;
}
/* 상단 표(224-1) — M02 구조물집계표 같은 부품 · 높이만 줄임(표 안에서 굴림) */
.ui-item-picker__table-wrap {
flex: none;
min-width: 0;
}
.ui-item-picker__table .ui-sheet__scroll {
max-height: 26vh;
}
.ui-item-picker__table-wrap[hidden],
.ui-item-picker__table[hidden] {
display: none;
}
.ui-item-picker__cols[hidden],
.ui-item-picker__actions[hidden],
.ui-item-picker .ui-modal__actions[hidden],
+12 -1
View File
@@ -8,6 +8,7 @@
*
* 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준.
* 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정).
* 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐).
* 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭.
* ========================================================================== */
@@ -43,6 +44,7 @@ import { askFields } from "./ui_template_item_picker_ask";
import { projectItems, type CreatedItem } from "./ui_template_item_picker_project";
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
import { createComboTable } from "./ui_template_item_picker_table";
export type { SummaryItem } from "./ui_template_item_picker_data";
@@ -146,6 +148,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
// 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸)
onCreated: (created) => void load(created.key),
});
const comboTable = createComboTable();
const cols = el("div", {
className: "ui-item-picker__cols",
children: [
@@ -156,6 +159,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
detailView.root,
],
});
const tableWrap = el("div", {
className: "ui-item-picker__table-wrap",
children: [comboTable.root],
});
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
let editView: EditView | null = null;
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
@@ -174,6 +181,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
const paintDetail = (): void => {
detailView.show(chosen);
comboTable.show(chosen);
const layer = chosen ? layerOf(chosen) : null;
applyButton.disabled = !chosen;
clearButton.disabled = !opts.current;
@@ -369,6 +377,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editView = view;
editHost.replaceChildren(view.root);
cols.hidden = true;
tableWrap.hidden = true;
actions.hidden = true;
headButtons.hidden = true;
editHost.hidden = false;
@@ -380,6 +389,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editView = null;
editHost.hidden = true;
cols.hidden = false;
tableWrap.hidden = false;
actions.hidden = false;
headButtons.hidden = false;
if (saved) detailView.markEdited();
@@ -396,11 +406,12 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
beforeClose: async () => {
if (editView && !(await editView.leave())) return false;
editView?.destroy();
comboTable.destroy();
resolve({ action: "cancel" });
return true;
},
mount: (body) => {
body.append(cols, editHost, ...(opts.projectId ? [actions] : []));
body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : []));
paintDetail();
void load(null);
},
@@ -85,6 +85,9 @@ export const ownerOf = (row: PickerRow): string | null =>
/** 같은 항목의 자리표 — 층 · 주인 · 키 */
export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
/** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */
export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`;
export function toSummary(row: PickerRow): SummaryItem {
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
}
@@ -0,0 +1,94 @@
/* =============================================================================
* ui_template_item_picker_table.ts
* 항목 고르기 모달 상단 표(PLAN 224-1) — M02 「구조물 조합」 페이지 상단의 구조물집계표를 **같은 부품**
* (`ui_template/sheet` `createSheet` · 줄 글 = `numberLabels` · 숨김 열 = `comboView`)으로 보임.
* 보기 전용(`readOnly` · 고침은 M02 에서) · 고른 항목이 속한 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐.
* 같은 층의 다른 항목을 고르면 표는 그대로 두고 열만 옮김 · 다른 층 항목이면 그 층 표로 바뀜.
* 표 부품 · 집계표 모듈은 처음 보일 때만 `import()`(B05 · B06 번들에 안 섞임).
* ========================================================================== */
import { el } from "./ui_template_elements";
import {
fetchStructureNumbersAt,
readTemplate,
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { layerOf, ownerOf, tableKey, type PickerRow } from "./ui_template_item_picker_data";
import { t } from "./ui_template_locale";
import type { SheetDoc, SheetHandle } from "./sheet/ui_template_sheet";
export interface ComboTable {
root: HTMLElement;
/** 고른 항목 — `null` 이면 표를 숨김 */
show: (row: PickerRow | null) => void;
destroy: () => void;
}
const failed = (error: unknown): string =>
t("M02_PickerTableFailed").replace(
"{value}",
error instanceof Error ? error.message : String(error),
);
export function createComboTable(): ComboTable {
const root = el("div", { className: "ui-item-picker__table", attrs: { hidden: "" } });
let seq = 0;
let shownKey: string | null = null;
let sheet: SheetHandle | null = null;
/** 가장 나중에 고른 열 — 표를 읽는 사이에 바뀌면 읽은 뒤 이 열을 고름 */
let column = "";
const drop = (): void => {
sheet?.destroy();
sheet = null;
shownKey = null;
};
async function paint(row: PickerRow, key: string, mine: number): Promise<void> {
drop();
root.replaceChildren(
el("p", { className: "ui-item-picker__hint", text: t("M02_PickerLoading") }),
);
try {
const layer = layerOf(row);
const owner = ownerOf(row);
const [{ createSheet }, struct] = await Promise.all([
import("./sheet/ui_template_sheet"),
import("../M02_MasterTemplete/M02_MasterTemplete_Structure"),
]);
const [got, numbers] = await Promise.all([
readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner),
fetchStructureNumbersAt(layer, owner).catch(() => ({})),
]);
if (mine !== seq) return;
sheet = createSheet(root, struct.comboView(got.문서) as SheetDoc, {
mode: "master",
readOnly: true,
lockHead: true,
combo: {},
...struct.numberLabels(() => numbers),
});
shownKey = key;
sheet.selectColumn(column);
} catch (error) {
if (mine === seq)
root.replaceChildren(el("p", { className: "ui-item-picker__hint", text: failed(error) }));
}
}
return {
root,
show: (row) => {
const mine = ++seq;
root.hidden = !row;
if (!row) return;
column = row.열;
const key = tableKey(row);
if (sheet && key === shownKey) return sheet.selectColumn(column);
void paint(row, key, mine);
},
destroy: () => {
seq += 1;
drop();
},
};
}
+1
View File
@@ -80,6 +80,7 @@ export const ui_locales_m2 = {
M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"],
M02_PickerLoading: ["읽는 중…", "Loading…"],
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
M02_PickerTableFailed: ["상단 표를 못 읽음 — {value}", "Failed to load the top table — {value}"],
M02_PickerDrawing: ["도면", "Drawing"],
M02_PickerArea: ["면적산출", "Area takeoff"],
M02_PickerUnitPrice: ["일위대가", "Unit price"],
+104 -57
View File
@@ -7,7 +7,9 @@
* 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로.
* 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈.
* 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움.
* WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸.
* 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움).
* WebGL canvas 는 그림이 남아 있을 때만 옮겨짐.
* CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸.
* ========================================================================== */
import "./ui_template_loupe.css";
@@ -33,6 +35,8 @@ export const LOUPE_EDGE = 4;
export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/;
const SVG_NS = "http://www.w3.org/2000/svg";
/** CAD 영역 — cad_host 가 끼운 상자와 iframe */
export const LOUPE_SKIP = ".cad-host, iframe";
const NON_TEXT_INPUT = new Set([
"checkbox",
"radio",
@@ -77,6 +81,11 @@ export function isTypingTarget(target: unknown): boolean {
return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase());
}
/** CAD 영역 위인지 — 돋보기를 안 띄우는 자리(221-1) */
export function isCadTarget(target: unknown): boolean {
return !!(target as Partial<Element> | null)?.closest?.(LOUPE_SKIP);
}
/** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */
export function nextLoupeZoom(zoom: number, deltaY: number): number {
const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP;
@@ -115,18 +124,36 @@ export function initLoupe(
let y = win.innerHeight / 2;
let press = false; // 오른쪽 길게 누름
let key = false; // Z 누름
let shown = false;
let shown = false; // 켜짐
let seen = false; // 원이 보임 — 켜짐이어도 CAD 위면 숨김
let overCad = false;
let holdTimer = 0;
let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음
let zoom = LOUPE_ZOOM;
const update = (): void => {
const want = (press || key) && isOn();
if (want && !shown) {
zoom = LOUPE_ZOOM; // 켤 때마다 2배
if (want && !shown) zoom = LOUPE_ZOOM; // 켤 때마다 2배
const see = want && !overCad;
if (see && !seen) {
view.show(x, y);
} else if (!want && shown) view.hide();
if (zoom !== LOUPE_ZOOM) view.setZoom(zoom); // CAD 에서 나옴 — 배율 그대로
} else if (!see && seen) view.hide();
shown = want;
seen = see;
};
const track = (e: MouseEvent): void => {
x = e.clientX;
y = e.clientY;
const cad = isCadTarget(e.target);
if (cad === overCad) return;
overCad = cad;
update();
};
const releaseZ = (e: KeyboardEvent): void => {
if (keyLetter(e) !== "z" || !key) return;
key = false;
update();
};
const stopHold = (): void => {
win.clearTimeout(holdTimer);
@@ -136,20 +163,21 @@ export function initLoupe(
win.addEventListener(type, fn, true);
on("mousemove", (e) => {
x = e.clientX;
y = e.clientY;
track(e);
if (!(e.buttons & 2) && (holdTimer || press)) {
stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴
press = false;
update();
}
if (shown) view.move(x, y);
if (seen) view.move(x, y);
});
on("mouseover", track); // iframe 위로 들어가면 mousemove 가 안 옴 — 들어갈 때 한 번
on("mousedown", (e) => {
if (e.button !== 2) return;
swallowMenu = false;
stopHold();
if (!isOn()) return;
if (!isOn() || isCadTarget(e.target)) return;
x = e.clientX;
y = e.clientY;
holdTimer = win.setTimeout(() => {
@@ -177,16 +205,12 @@ export function initLoupe(
key = true;
update();
});
on("keyup", (e) => {
if (keyLetter(e) !== "z" || !key) return;
key = false;
update();
});
on("keyup", releaseZ);
// 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음
win.addEventListener(
"wheel",
(e) => {
if (!shown) return;
if (!seen) return; // CAD 위 — CAD 휠 줌 그대로
e.preventDefault();
e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음
zoom = nextLoupeZoom(zoom, e.deltaY);
@@ -195,7 +219,19 @@ export function initLoupe(
{ capture: true, passive: false },
);
on("blur", (e) => {
if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함)
if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만
const active = win.document?.activeElement as HTMLIFrameElement | null | undefined;
if (isCadTarget(active)) {
// 커서가 CAD 로 들어가 초점이 넘어감(cad_host) — 끄지 않고 숨김만 · Z 떼기는 그쪽 창에서 들음
try {
active?.contentWindow?.addEventListener("keyup", releaseZ, true);
} catch {
// 다른 출처 — 나올 때 Z 를 다시 눌러야 꺼짐
}
overCad = true;
update();
return;
}
stopHold();
press = key = false;
update();
@@ -215,6 +251,8 @@ export function createLoupeView(
let at: [number, number] = [0, 0]; // 마지막 커서 — 배율만 바뀔 때 다시 놓음
let observer: MutationObserver | null = null;
let refreshTimer = 0;
let sync = (): void => {}; // 스크롤 위치 맞춤 — 뜰 때마다 새로
let syncFrame = 0;
const build = (): void => {
root = doc.createElement("div");
@@ -236,7 +274,7 @@ export function createLoupeView(
const source = sourceOf();
const t0 = win.performance?.now() ?? 0;
stage!.replaceChildren();
if (source) captureInto(source, stage!, win);
if (source) sync = captureInto(source, stage!, win);
root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0));
};
const refresh = (): void => {
@@ -246,11 +284,19 @@ export function createLoupeView(
if (observer) capture();
}, LOUPE_REFRESH_MS);
};
const onScroll = (): void => {
if (!syncFrame)
syncFrame = win.requestAnimationFrame(() => {
syncFrame = 0;
sync();
});
refresh();
};
const view: LoupeView = {
show(x, y) {
if (!root) build();
root!.hidden = label!.hidden = false; // 숨긴 채 뜨면 복제의 스크롤이 안 먹음 — 먼저 보임
capture();
root!.hidden = label!.hidden = false;
at = [x, y];
view.setZoom(LOUPE_ZOOM); // 켤 때마다 2배 · 자리도 놓음
const source = sourceOf();
@@ -264,7 +310,7 @@ export function createLoupeView(
characterData: true,
});
observer = watch;
win.addEventListener("scroll", refresh, true);
win.addEventListener("scroll", onScroll, true);
},
move(x, y) {
if (!root || !stage || !label) return;
@@ -286,7 +332,9 @@ export function createLoupeView(
observer = null;
win.clearTimeout(refreshTimer);
refreshTimer = 0;
win.removeEventListener("scroll", refresh, true);
win.cancelAnimationFrame(syncFrame);
syncFrame = 0;
win.removeEventListener("scroll", onScroll, true);
stage?.replaceChildren(); // 복제를 지움
if (root) root.hidden = label!.hidden = true;
},
@@ -294,8 +342,8 @@ export function createLoupeView(
return view;
}
/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 */
export function captureInto(source: Element, stage: HTMLElement, win: Window): void {
/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 — 돌려주는 함수 = 지금 스크롤 · 자리로 다시 맞춤 */
export function captureInto(source: Element, stage: HTMLElement, win: Window): () => void {
const doc = win.document;
const rect = source.getBoundingClientRect();
const page = doc.createElement("div");
@@ -307,14 +355,21 @@ export function captureInto(source: Element, stage: HTMLElement, win: Window): v
height: `${rect.height}px`,
});
const copy = source.cloneNode(true) as Element;
const scrolls: [Element, number, number][] = [];
const scrolls: [Element, Element][] = []; // [원본, 복제] 스크롤 칸
walk(source, copy, doc, win.innerWidth, win.innerHeight, scrolls);
page.append(copy);
stage.append(page);
for (const [el, top, left] of scrolls) {
el.scrollTop = top; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 되돌림
el.scrollLeft = left;
}
const sync = (): void => {
const now = source.getBoundingClientRect(); // 문서 스크롤 — 페이지 자리
page.style.left = `${now.left}px`;
page.style.top = `${now.top}px`;
for (const [o, c] of scrolls) {
c.scrollTop = o.scrollTop; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 옮김
c.scrollLeft = o.scrollLeft;
}
};
sync();
return sync;
}
function walk(
@@ -323,24 +378,27 @@ function walk(
doc: Document,
vw: number,
vh: number,
scrolls: [Element, number, number][],
scrolls: [Element, Element][],
): void {
if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로
const tag = o.tagName.toUpperCase();
if (tag === "CANVAS") return drawCanvas(o as HTMLCanvasElement, c as HTMLCanvasElement);
if (tag === "IFRAME") return void c.replaceWith(frameShot(o as HTMLIFrameElement, doc));
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return copyValue(o, c);
const r = o.getBoundingClientRect();
if (!KEEP_INSIDE.has(tag) && (r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw)) {
c.replaceChildren(); // 화면 밖 — 속을 비우고 크기만
Object.assign((c as HTMLElement).style, {
boxSizing: "border-box",
width: `${r.width}px`,
height: `${r.height}px`,
});
return;
if (tag === "IFRAME") {
const box = doc.createElement("div"); // 복제 iframe 은 CAD 를 또 띄움 — 빈 상자로 바꿈
c.replaceWith(box);
return emptyBox(box, r);
}
if (o.scrollTop || o.scrollLeft) scrolls.push([c, o.scrollTop, o.scrollLeft]);
const off = r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw;
if (o.classList?.contains("cad-host") || (!KEEP_INSIDE.has(tag) && off)) return emptyBox(c, r); // CAD · 화면 밖
if (
o.scrollTop ||
o.scrollLeft ||
o.scrollHeight > o.clientHeight ||
o.scrollWidth > o.clientWidth
)
scrolls.push([o, c]); // 지금 스크롤됐거나 스크롤될 수 있는 칸
for (let i = 0; i < o.children.length; i++)
walk(o.children[i], c.children[i], doc, vw, vh, scrolls);
}
@@ -364,23 +422,12 @@ function copyValue(o: Element, c: Element): void {
}
}
/** iframe 자리 — 같은 출처면 안의 canvas 를 한 장에 모아 그림 · 아니면 빈칸 */
function frameShot(frame: HTMLIFrameElement, doc: Document): HTMLCanvasElement {
const r = frame.getBoundingClientRect();
const shot = doc.createElement("canvas");
shot.className = frame.className;
shot.width = Math.max(1, Math.round(r.width));
shot.height = Math.max(1, Math.round(r.height));
Object.assign(shot.style, { display: "block", width: `${r.width}px`, height: `${r.height}px` });
try {
const ctx = shot.getContext("2d");
for (const canvas of Array.from(frame.contentDocument?.querySelectorAll("canvas") ?? [])) {
const cr = canvas.getBoundingClientRect();
if (canvas.width && canvas.height)
ctx?.drawImage(canvas, cr.left, cr.top, cr.width, cr.height);
}
} catch {
// 다른 출처 · 그림 없음 — 빈칸
}
return shot;
/** 속을 비우고 크기만 남김 — 화면 밖 · CAD 영역 */
function emptyBox(c: Element, r: DOMRect): void {
c.replaceChildren();
Object.assign((c as HTMLElement).style, {
boxSizing: "border-box",
width: `${r.width}px`,
height: `${r.height}px`,
});
}