254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { Point } from '@flatten-js/core';
|
|
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
|
|
import { ImageEntity } from '../entities/ImageEntity.ts';
|
|
import { TextEntity } from '../entities/TextEntity.ts';
|
|
import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts';
|
|
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
|
|
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
|
|
import {
|
|
clearDrawingDirty,
|
|
getCanvas,
|
|
getDesignMeta,
|
|
getEntities,
|
|
getFrameFields,
|
|
getLayers,
|
|
getScreenCanvasDrawController,
|
|
isDrawingDirty,
|
|
isDrawingReadOnly,
|
|
isViewOnly,
|
|
resetUndoBaseline,
|
|
setActiveLayerId,
|
|
setDesignMeta,
|
|
setEntities,
|
|
setFrameEditMode,
|
|
setHostReadOnly,
|
|
setHostSave,
|
|
setHostTitle,
|
|
setLayers,
|
|
setOriginMarker,
|
|
setViewOnly,
|
|
} from '../state.ts';
|
|
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
|
|
import { toast } from 'react-toastify';
|
|
import { runCommandInput } from '../commands/run-command.ts';
|
|
import { setRecoveryScope } from '../helpers/autosave.ts';
|
|
import { registerBoxResizeDrag } from './box-resize-drag.ts';
|
|
|
|
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
|
|
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
|
|
export const AISLO_DRAWING_LOADED_MESSAGE = 'aislo:b08:drawing-loaded';
|
|
export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b08:drawing-error';
|
|
export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed';
|
|
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
|
|
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
|
|
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
|
|
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
|
|
export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
|
|
|
|
interface DrawingLoadMessage {
|
|
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
|
|
drawing: JsonDrawingFileSerialized;
|
|
meta?: DesignMeta | null;
|
|
/** 도각 편집으로 실은 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다. */
|
|
frameEdit?: boolean;
|
|
/** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */
|
|
frameFields?: Record<string, string>;
|
|
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */
|
|
recoveryScope?: string;
|
|
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
|
|
readOnly?: boolean;
|
|
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
|
|
hostTitle?: string;
|
|
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */
|
|
hostSave?: boolean;
|
|
/** 리본을 접힌 채로 여는가 — 없으면(B07) 펼침 그대로. */
|
|
ribbonCollapsed?: boolean;
|
|
/** 좌측 패널(특성 · 도면층)을 접힌 채로 여는가 — 없으면(B07) 지금 상태 그대로. */
|
|
panelsCollapsed?: boolean;
|
|
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
|
viewOnly?: boolean;
|
|
originMarker?: boolean;
|
|
}
|
|
|
|
interface DrawingSaveRequestMessage {
|
|
type: typeof AISLO_DRAWING_SAVE_REQUEST_MESSAGE;
|
|
}
|
|
|
|
function isDrawingLoadMessage(value: unknown): value is DrawingLoadMessage {
|
|
if (!value || typeof value !== 'object') return false;
|
|
const candidate = value as Partial<DrawingLoadMessage>;
|
|
return candidate.type === AISLO_DRAWING_LOAD_MESSAGE && Boolean(candidate.drawing);
|
|
}
|
|
|
|
function notifyParent(type: string, payload: Record<string, unknown> = {}) {
|
|
if (window.parent === window) return;
|
|
window.parent.postMessage({ type, ...payload }, window.location.origin);
|
|
}
|
|
|
|
/** 수량 패널에서 이전/다음 도면으로 이동 요청 (부모가 처리). */
|
|
export function requestDrawingNavigation(direction: 'prev' | 'next') {
|
|
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
|
|
}
|
|
|
|
/** 저장 요청을 부모 페이지 [저장]에 넘긴다 — 캐드는 양식 파일을 모른다. */
|
|
export function requestHostSave() {
|
|
notifyParent(AISLO_DRAWING_HOST_SAVE_MESSAGE);
|
|
}
|
|
|
|
/**
|
|
* 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시).
|
|
* 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다.
|
|
*/
|
|
export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') {
|
|
notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat });
|
|
}
|
|
|
|
/**
|
|
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
|
|
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
|
|
* 화면과 저장값이 어긋났다.
|
|
*/
|
|
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
|
|
|
|
/**
|
|
* 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다.
|
|
* CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외.
|
|
*/
|
|
function registerTextDoubleClickEdit() {
|
|
const canvas = getCanvas();
|
|
if (!canvas) return;
|
|
canvas.addEventListener('dblclick', (event: MouseEvent) => {
|
|
if (isViewOnly()) {
|
|
getScreenCanvasDrawController().zoomToFitScreen();
|
|
return;
|
|
}
|
|
if (isDrawingReadOnly()) {
|
|
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
|
|
return;
|
|
}
|
|
const drawController = getScreenCanvasDrawController();
|
|
const bounds = canvas.getBoundingClientRect();
|
|
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
|
|
const worldPoint = drawController.targetToWorld(screenPoint);
|
|
const lockedLayerIds = new Set(
|
|
getLayers()
|
|
.filter((layer) => layer.isLocked)
|
|
.map((layer) => layer.id)
|
|
);
|
|
let closest: TextEntity | null = null;
|
|
let closestDistance = Number.MAX_SAFE_INTEGER;
|
|
let closestOnQuantityTable = false;
|
|
for (const entity of getEntities()) {
|
|
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
|
|
const distanceInfo = entity.distanceTo(worldPoint);
|
|
if (distanceInfo && distanceInfo[0] < closestDistance) {
|
|
closestDistance = distanceInfo[0];
|
|
closest = entity;
|
|
closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID;
|
|
}
|
|
}
|
|
if (!closest) return;
|
|
const fontSize = closest.getTextOptions().fontSize;
|
|
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
|
|
if (closestDistance > tolerance) return;
|
|
if (closestOnQuantityTable) {
|
|
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
|
|
return;
|
|
}
|
|
const nextLabel = window.prompt('값 수정', closest.getLabel());
|
|
if (nextLabel === null || nextLabel === closest.getLabel()) return;
|
|
closest.setLabel(nextLabel);
|
|
setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 자리표에 실제 값을 입힌다 — 저장값은 그대로 두고 **보여 주기만** 바꾼다
|
|
* (2026-09-06 사용자 지시). 편집 화면에서 어디에 무엇이 들어가는지 보이게 하는 것이 목적.
|
|
*/
|
|
function applyFramePreview(): void {
|
|
const fields = getFrameFields();
|
|
if (Object.keys(fields).length === 0) return;
|
|
const token = /^\{\{(.+)\}\}$/;
|
|
for (const entity of getEntities()) {
|
|
if (entity instanceof TextEntity) {
|
|
const match = token.exec(entity.getLabel().trim());
|
|
const value = match ? fields[match[1]] : undefined;
|
|
entity.previewLabel = value ?? null;
|
|
} else if (entity instanceof ImageEntity && entity.isPlaceholder()) {
|
|
const match = token.exec((entity.getSourceData() ?? '').trim());
|
|
const value = match ? fields[match[1]] : undefined;
|
|
if (value) entity.setPreviewImage(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* B08 parent page와 CAD 앱 사이의 same-origin JSON 경계다.
|
|
* DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다.
|
|
*/
|
|
export function registerAisloDrawingBridge() {
|
|
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
|
|
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
|
const message = event.data as Partial<DrawingSaveRequestMessage>;
|
|
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
|
|
try {
|
|
const drawing = JSON.parse(
|
|
await exportEntitiesAndLayersToJsonString()
|
|
) as JsonDrawingFileSerialized;
|
|
// 수량표는 앞 단계 산출값을 그대로 돌려준다 — B07에서는 고치지 않는다.
|
|
const quantityTable = getDesignMeta()?.quantityTable ?? null;
|
|
clearDrawingDirty(); // 부모가 받아 저장한다 — 미저장 경고 대상이 아니다
|
|
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable });
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
|
|
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
|
|
}
|
|
return;
|
|
}
|
|
if (!isDrawingLoadMessage(event.data)) return;
|
|
|
|
try {
|
|
const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing);
|
|
setEntities(drawing.entities, false);
|
|
setLayers(drawing.layers);
|
|
setActiveLayerId(drawing.layers[0].id);
|
|
// 실은 도면이 되돌리기의 바닥 — 첫 Ctrl+Z가 백지로 가지 않게 한다.
|
|
// (미저장 표시도 여기서 내려간다 — 방금 서버 도면을 실었으니 고친 것이 없다.)
|
|
resetUndoBaseline();
|
|
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
|
|
setDesignMeta(event.data.meta ?? null);
|
|
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
|
|
setHostReadOnly(event.data.readOnly === true);
|
|
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
|
|
setHostSave(event.data.hostSave === true);
|
|
setRibbonCollapsed(event.data.ribbonCollapsed === true);
|
|
if (event.data.panelsCollapsed) setInspectorCollapsed(true);
|
|
setViewOnly(event.data.viewOnly === true);
|
|
setOriginMarker(event.data.originMarker === true);
|
|
if (event.data.frameEdit) applyFramePreview();
|
|
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
|
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
|
// 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중
|
|
// 상태를 이어 갈 이유도 없다.
|
|
runCommandInput('SELECT');
|
|
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
|
|
setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null);
|
|
getScreenCanvasDrawController().zoomToFitScreen();
|
|
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : 'Invalid drawing JSON';
|
|
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
|
|
}
|
|
});
|
|
// 부모는 이 통지로 **미저장 여부만** 안다. 확정을 푸는 것은 [수정] 버튼 한 곳뿐이다
|
|
// (2026-09-01 사용자 확정) — 예전에는 되돌리기·색 고르기까지 확정을 풀었다.
|
|
window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => {
|
|
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() });
|
|
});
|
|
registerTextDoubleClickEdit();
|
|
registerBoxResizeDrag();
|
|
|
|
notifyParent(AISLO_DRAWING_READY_MESSAGE);
|
|
}
|