refactor(B07): 자리표 팔레트를 CAD 내부 패널로 이동
- 부모 사이드바 팔레트(B07_DesignDetail_UI_FramePlaceholders.ts) 제거, CAD 앱에 FramePlaceholderPanel 신설 - 도면 로드 메시지에 frameEdit 플래그 추가 — 도각 편집으로 실은 도면에서만 패널 표시 - 자리표를 화면 중앙에 직접 생성 (부모 왕복 없이 CAD 상태에 추가, 실행취소 스택 반영) - 그림 자리표는 값이 없어도 테두리와 이름을 그려 배치 위치가 보이게 함 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,23 +16,21 @@ import {
|
||||
resetFrameTemplate,
|
||||
saveFrameTemplate,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { createPlaceholderPalette } from "./B07_DesignDetail_UI_FramePlaceholders";
|
||||
|
||||
export interface FrameTemplateEditor {
|
||||
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
|
||||
button: HTMLButtonElement;
|
||||
/** 편집 중임을 알리는 띠 — 도면 목록 하단 액션 칸의 1행 (평소엔 숨김). */
|
||||
banner: HTMLElement;
|
||||
/** 자리표 목록 — 편집 중에만 보인다. */
|
||||
tokens: HTMLElement;
|
||||
/** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */
|
||||
isEditing: () => boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
projectId: string;
|
||||
/** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). */
|
||||
sendLoad: (drawing: CadDrawing, meta: null) => void;
|
||||
/** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다).
|
||||
* frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */
|
||||
sendLoad: (drawing: CadDrawing, meta: null, frameEdit?: boolean) => void;
|
||||
/** CAD에서 현재 편집본을 받아온다. */
|
||||
requestCadDrawing: () => Promise<CadDrawing>;
|
||||
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
|
||||
@@ -92,11 +90,6 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
bannerButtons.append(finishButton, importButton, resetButton, cancelButton);
|
||||
banner.append(bannerButtons, fileInput);
|
||||
|
||||
const palette = createPlaceholderPalette({
|
||||
requestCadDrawing: () => options.requestCadDrawing(),
|
||||
sendLoad: (drawing, meta) => options.sendLoad(drawing, meta),
|
||||
});
|
||||
|
||||
const button = createButton({
|
||||
label: "도각 편집",
|
||||
variant: "ghost",
|
||||
@@ -106,7 +99,6 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
const leave = (): void => {
|
||||
editing = false;
|
||||
banner.hidden = true;
|
||||
palette.setVisible(false);
|
||||
button.disabled = false;
|
||||
options.restoreDrawing();
|
||||
};
|
||||
@@ -118,7 +110,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
importButton.disabled = true;
|
||||
try {
|
||||
const response = await importFrameTemplate(options.projectId, file);
|
||||
options.sendLoad(response.drawing, null);
|
||||
options.sendLoad(response.drawing, null, true);
|
||||
label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`;
|
||||
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
|
||||
} catch (error) {
|
||||
@@ -137,11 +129,10 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
editing = true;
|
||||
button.disabled = true;
|
||||
banner.hidden = false;
|
||||
palette.setVisible(true);
|
||||
label.textContent = response.customized
|
||||
? "도각 편집 중 — 회사 도각을 고치고 있습니다."
|
||||
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
|
||||
options.sendLoad(response.drawing, null);
|
||||
options.sendLoad(response.drawing, null, true);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
|
||||
}
|
||||
@@ -187,5 +178,5 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
}
|
||||
}
|
||||
|
||||
return { button, banner, tokens: palette.root, isEditing: () => editing };
|
||||
return { button, banner, isEditing: () => editing };
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* B07 도각 자리표 배치 — 프로그램 값이 들어갈 자리를 사용자가 직접 놓는다 (2026-09-06 사용자 확정).
|
||||
*
|
||||
* 값을 알아맞히는 규칙은 만들지 않는다. 여기서 놓은 `{{키}}` 토큰을 출력 때 기존 치환
|
||||
* 엔진(`_fill_placeholders`)이 그대로 채운다. 자리표를 안 놓은 값은 빈칸으로 남는다.
|
||||
*
|
||||
* 놓는 방식 — 단추를 누르면 도면 한가운데에 자리표가 서고, 그 뒤 캐드의 이동·크기 도구로
|
||||
* 자리를 잡는다. 캐드 안쪽 코드는 건드리지 않는다(도면을 통째로 다시 싣는 방식).
|
||||
*/
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import type { CadDrawing } from "./B07_DesignDetail_Api_Fetch";
|
||||
|
||||
/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */
|
||||
const TEXT_TOKENS: readonly string[] = [
|
||||
"도면명",
|
||||
"도면번호",
|
||||
"공사명",
|
||||
"위치",
|
||||
"시행청",
|
||||
"용역회사",
|
||||
"연도기번",
|
||||
"사업량",
|
||||
"과업책임자",
|
||||
"분야별책임자",
|
||||
"설계자",
|
||||
"설계일자",
|
||||
"축척_A1",
|
||||
"축척_A3",
|
||||
];
|
||||
|
||||
/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 그림째 빠진다. */
|
||||
const IMAGE_TOKENS: readonly string[] = [
|
||||
"회사로고",
|
||||
"과업책임자서명",
|
||||
"분야별책임자서명",
|
||||
"설계자서명",
|
||||
];
|
||||
|
||||
const TEXT_SIZE_MM = 5;
|
||||
const IMAGE_WIDTH_MM = 32;
|
||||
const IMAGE_HEIGHT_MM = 16;
|
||||
/** 겹쳐 놓지 않도록 하나 놓을 때마다 이만큼 내려 찍는다. */
|
||||
const STACK_STEP_MM = 8;
|
||||
|
||||
interface Options {
|
||||
/** CAD에서 현재 편집본을 받아온다. */
|
||||
requestCadDrawing: () => Promise<CadDrawing>;
|
||||
/** CAD에 도면을 다시 싣는다. */
|
||||
sendLoad: (drawing: CadDrawing, meta: null) => void;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** 엔티티 목록의 한가운데 — 자리표를 처음 놓는 자리. 좌표가 없으면 원점. */
|
||||
function centerOf(entities: Record<string, unknown>[]): Point {
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
const visit = (entity: Record<string, unknown>): void => {
|
||||
const shape = (entity.shapeData ?? {}) as Record<string, unknown>;
|
||||
for (const key of ["startPoint", "endPoint", "basePoint", "point"]) {
|
||||
const value = shape[key] as Point | undefined;
|
||||
if (value && typeof value.x === "number" && typeof value.y === "number") {
|
||||
xs.push(value.x);
|
||||
ys.push(value.y);
|
||||
}
|
||||
}
|
||||
for (const vertex of (shape.points as Point[] | undefined) ?? []) {
|
||||
if (vertex && typeof vertex.x === "number") {
|
||||
xs.push(vertex.x);
|
||||
ys.push(vertex.y);
|
||||
}
|
||||
}
|
||||
for (const child of (entity.children as Record<string, unknown>[] | undefined) ?? []) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
for (const entity of entities) visit(entity);
|
||||
if (xs.length === 0) return { x: 0, y: 0 };
|
||||
const mid = (values: number[]): number => (Math.min(...values) + Math.max(...values)) / 2;
|
||||
return { x: mid(xs), y: mid(ys) };
|
||||
}
|
||||
|
||||
function layerIdOf(drawing: CadDrawing): string {
|
||||
return drawing.layers[0]?.id ?? "0";
|
||||
}
|
||||
|
||||
function textEntity(token: string, at: Point, layerId: string): Record<string, unknown> {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "Text",
|
||||
lineColor: "#f5f7fa",
|
||||
lineWidth: 1,
|
||||
layerId,
|
||||
shapeData: {
|
||||
label: `{{${token}}}`,
|
||||
basePoint: { x: at.x, y: at.y },
|
||||
options: {
|
||||
textDirection: { x: 1, y: 0 },
|
||||
textAlign: "center",
|
||||
textColor: "#f5f7fa",
|
||||
fontSize: TEXT_SIZE_MM,
|
||||
fontFamily: "sans-serif",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function imageEntity(token: string, at: Point, layerId: string): Record<string, unknown> {
|
||||
const halfWidth = IMAGE_WIDTH_MM / 2;
|
||||
const halfHeight = IMAGE_HEIGHT_MM / 2;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "Image",
|
||||
lineColor: "#f5f7fa",
|
||||
lineWidth: 1,
|
||||
layerId,
|
||||
shapeData: {
|
||||
points: [
|
||||
{ x: at.x - halfWidth, y: at.y - halfHeight },
|
||||
{ x: at.x + halfWidth, y: at.y - halfHeight },
|
||||
{ x: at.x + halfWidth, y: at.y + halfHeight },
|
||||
{ x: at.x - halfWidth, y: at.y + halfHeight },
|
||||
],
|
||||
imageData: `{{${token}}}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlaceholderPalette {
|
||||
/** 도각 편집 띠 아래에 붙는 자리표 목록 (평소엔 숨김). */
|
||||
root: HTMLElement;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export function createPlaceholderPalette(options: Options): PlaceholderPalette {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b07-frame-tokens";
|
||||
root.hidden = true;
|
||||
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "b07-frame-tokens__hint";
|
||||
hint.textContent = "자리표 놓기 — 누르면 도면 가운데에 서고, 끌어서 자리를 잡음";
|
||||
root.append(hint);
|
||||
|
||||
let placed = 0;
|
||||
|
||||
const place = async (token: string, kind: "text" | "image"): Promise<void> => {
|
||||
try {
|
||||
const drawing = await options.requestCadDrawing();
|
||||
const center = centerOf(drawing.entities);
|
||||
const at = { x: center.x, y: center.y - placed * STACK_STEP_MM };
|
||||
const layerId = layerIdOf(drawing);
|
||||
const entity =
|
||||
kind === "text" ? textEntity(token, at, layerId) : imageEntity(token, at, layerId);
|
||||
options.sendLoad({ ...drawing, entities: [...drawing.entities, entity] }, null);
|
||||
placed += 1;
|
||||
showToast(`「${token}」 자리표를 놓았습니다. 끌어서 자리를 잡으십시오.`, "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "자리표를 놓지 못했습니다.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const group = (label: string, tokens: readonly string[], kind: "text" | "image"): void => {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b07-frame-tokens__group";
|
||||
const title = document.createElement("span");
|
||||
title.className = "b07-frame-tokens__title";
|
||||
title.textContent = label;
|
||||
box.append(title);
|
||||
for (const token of tokens) {
|
||||
box.append(
|
||||
createButton({
|
||||
label: token,
|
||||
variant: "ghost",
|
||||
onClick: () => void place(token, kind),
|
||||
}),
|
||||
);
|
||||
}
|
||||
root.append(box);
|
||||
};
|
||||
|
||||
group("글자", TEXT_TOKENS, "text");
|
||||
group("그림", IMAGE_TOKENS, "image");
|
||||
|
||||
return {
|
||||
root,
|
||||
setVisible: (visible: boolean) => {
|
||||
root.hidden = !visible;
|
||||
if (!visible) placed = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadHost.append(frame, license);
|
||||
|
||||
let cadReady = false;
|
||||
let pendingLoad: { drawing: CadDrawing; meta: DesignMeta | null } | undefined;
|
||||
let pendingLoad: { drawing: CadDrawing; meta: DesignMeta | null; frameEdit: boolean } | undefined;
|
||||
let currentDrawing: DesignDrawingItem | undefined;
|
||||
let currentIndex = -1;
|
||||
let currentConfirmed = false;
|
||||
@@ -205,11 +205,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
hasNext: index < drawings.length - 1,
|
||||
});
|
||||
|
||||
const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null) => {
|
||||
pendingLoad = { drawing, meta };
|
||||
// frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
|
||||
// (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김).
|
||||
const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null, frameEdit = false) => {
|
||||
pendingLoad = { drawing, meta, frameEdit };
|
||||
if (!cadReady) return;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_LOAD_MESSAGE, drawing, meta },
|
||||
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit },
|
||||
window.location.origin,
|
||||
);
|
||||
pendingLoad = undefined;
|
||||
@@ -446,7 +448,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
} else if (message.type === CAD_READY_MESSAGE) {
|
||||
cadReady = true;
|
||||
if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta);
|
||||
if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta, pendingLoad.frameEdit);
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
cadHost.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
@@ -481,7 +483,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const confirmButtonRow = document.createElement("div");
|
||||
confirmButtonRow.className = "b07-drawing-actions__row";
|
||||
confirmButtonRow.append(frameEditor.button, confirmButton);
|
||||
confirmActions.append(frameEditor.banner, frameEditor.tokens, confirmButtonRow);
|
||||
confirmActions.append(frameEditor.banner, confirmButtonRow);
|
||||
|
||||
drawingPanel.append(infoPanelHost, confirmActions);
|
||||
|
||||
|
||||
@@ -321,43 +321,3 @@
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* 도각 자리표 목록 — 편집 중에만 보인다 (2026-09-06). 사이드바가 좁아 단추를 감싼다. */
|
||||
.b07-frame-tokens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
margin-top: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.b07-frame-tokens[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b07-frame-tokens__hint {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b07-frame-tokens__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.b07-frame-tokens__title {
|
||||
width: 100%;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b07-frame-tokens__group > button {
|
||||
padding: 2px 6px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@@ -942,3 +942,58 @@ body > canvas[data-id="canvas"] {
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로
|
||||
부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */
|
||||
.cad-frame-tokens {
|
||||
position: fixed;
|
||||
/* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */
|
||||
right: 12px;
|
||||
bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px);
|
||||
z-index: 3;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 236px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 6px;
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--cad-text);
|
||||
}
|
||||
|
||||
.cad-frame-tokens__header {
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__title {
|
||||
width: 100%;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__button {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__button:hover {
|
||||
background: var(--cad-accent-soft, var(--cad-chrome-raised));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './App.css';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { FramePlaceholderPanel } from './components/FramePlaceholderPanel.tsx';
|
||||
import { QuantityPanel } from './components/QuantityPanel.tsx';
|
||||
import { Toolbar } from './components/Toolbar.tsx';
|
||||
|
||||
@@ -8,6 +9,7 @@ function App() {
|
||||
<div className="cad-app">
|
||||
<Toolbar />
|
||||
<QuantityPanel />
|
||||
<FramePlaceholderPanel />
|
||||
<ToastContainer position="bottom-right" theme="light" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { type FC, useCallback, useEffect, useState } from 'react';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { ImageEntity } from '../entities/ImageEntity';
|
||||
import { TextEntity } from '../entities/TextEntity';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getScreenCanvasDrawController,
|
||||
isFrameEditMode,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
|
||||
/**
|
||||
* 도각 자리표 패널 — 프로그램 값이 들어갈 자리를 사용자가 직접 놓는다 (2026-09-06 사용자 확정).
|
||||
*
|
||||
* 값을 알아맞히는 규칙은 만들지 않는다. 여기서 놓은 `{{키}}` 토큰을 도면 출력 때 서버의
|
||||
* 치환 엔진이 채운다. 자리표를 안 놓은 값은 빈칸으로 남는다.
|
||||
*
|
||||
* 도각 편집으로 도면을 실었을 때만 뜬다. 놓은 자리표는 화면 한가운데에 서고, 그 뒤
|
||||
* 캐드의 이동·크기 도구로 자리를 잡는다.
|
||||
*/
|
||||
|
||||
/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */
|
||||
const TEXT_TOKENS = [
|
||||
'도면명',
|
||||
'도면번호',
|
||||
'공사명',
|
||||
'위치',
|
||||
'시행청',
|
||||
'용역회사',
|
||||
'연도기번',
|
||||
'사업량',
|
||||
'과업책임자',
|
||||
'분야별책임자',
|
||||
'설계자',
|
||||
'설계일자',
|
||||
'축척_A1',
|
||||
'축척_A3',
|
||||
] as const;
|
||||
|
||||
/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 도면에서 그림째 빠진다. */
|
||||
const IMAGE_TOKENS = ['회사로고', '과업책임자서명', '분야별책임자서명', '설계자서명'] as const;
|
||||
|
||||
const TEXT_SIZE_MM = 5;
|
||||
const IMAGE_WIDTH_MM = 32;
|
||||
const IMAGE_HEIGHT_MM = 16;
|
||||
|
||||
/** 지금 보고 있는 화면의 한가운데 (도면 좌표). 자리표가 처음 서는 자리다. */
|
||||
function viewCenter(): Point {
|
||||
const drawController = getScreenCanvasDrawController();
|
||||
const size = drawController.getCanvasSize();
|
||||
return drawController.targetToWorld(new Point(size.x / 2, size.y / 2));
|
||||
}
|
||||
|
||||
function addTextPlaceholder(token: string): void {
|
||||
const center = viewCenter();
|
||||
const entity = new TextEntity(getActiveLayerId(), `{{${token}}}`, center, {
|
||||
fontSize: TEXT_SIZE_MM,
|
||||
textAlign: 'center',
|
||||
});
|
||||
setEntities([...getEntities(), entity], true);
|
||||
}
|
||||
|
||||
async function addImagePlaceholder(token: string): Promise<void> {
|
||||
const center = viewCenter();
|
||||
const halfWidth = IMAGE_WIDTH_MM / 2;
|
||||
const halfHeight = IMAGE_HEIGHT_MM / 2;
|
||||
const points = [
|
||||
{ x: center.x - halfWidth, y: center.y - halfHeight },
|
||||
{ x: center.x + halfWidth, y: center.y - halfHeight },
|
||||
{ x: center.x + halfWidth, y: center.y + halfHeight },
|
||||
{ x: center.x - halfWidth, y: center.y + halfHeight },
|
||||
];
|
||||
// 자리표는 그림 주소가 아니라 토큰이라 fromJson 으로 만든다 — 그래야 원본 문자열이
|
||||
// 그대로 보존돼 저장 한 번에 주소로 굳지 않는다.
|
||||
const entity = await ImageEntity.fromJson({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'Image',
|
||||
lineColor: '#f5f7fa',
|
||||
lineWidth: 1,
|
||||
layerId: getActiveLayerId(),
|
||||
shapeData: { points, imageData: `{{${token}}}` },
|
||||
} as Parameters<typeof ImageEntity.fromJson>[0]);
|
||||
setEntities([...getEntities(), entity], true);
|
||||
}
|
||||
|
||||
export const FramePlaceholderPanel: FC = () => {
|
||||
const [visible, setVisible] = useState(isFrameEditMode());
|
||||
|
||||
const refresh = useCallback(() => setVisible(isFrameEditMode()), []);
|
||||
useEffect(() => {
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<section className="cad-frame-tokens controls">
|
||||
<header className="cad-frame-tokens__header">
|
||||
자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다
|
||||
</header>
|
||||
<div className="cad-frame-tokens__group">
|
||||
<span className="cad-frame-tokens__title">글자</span>
|
||||
{TEXT_TOKENS.map((token) => (
|
||||
<button
|
||||
key={token}
|
||||
type="button"
|
||||
className="cad-frame-tokens__button"
|
||||
onClick={() => addTextPlaceholder(token)}
|
||||
>
|
||||
{token}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cad-frame-tokens__group">
|
||||
<span className="cad-frame-tokens__title">그림</span>
|
||||
{IMAGE_TOKENS.map((token) => (
|
||||
<button
|
||||
key={token}
|
||||
type="button"
|
||||
className="cad-frame-tokens__button"
|
||||
onClick={() => void addImagePlaceholder(token)}
|
||||
>
|
||||
{token}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Flatten from '@flatten-js/core';
|
||||
import { type Box, Point, Polygon, Relations, type Segment, Vector } from '@flatten-js/core';
|
||||
import { type Shape, type SnapPoint, SnapPointType } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController.ts';
|
||||
import { twoPointBoxToPolygon } from '../helpers/box-to-polygon';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { mirrorAngleOverAxis } from '../helpers/mirror-angle-over-axis.ts';
|
||||
@@ -69,14 +69,28 @@ export class ImageEntity implements Entity {
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
// 테두리는 **집었을 때만** 그린다. 늘 그리면 도각의 로고·서명 자리에 흰 사각형이
|
||||
// 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02).
|
||||
if (highlighted || selected) {
|
||||
// 자리표(`{{회사로고}}` 등)는 그림이 없어 화면에 아무것도 안 보였다 — 도각 편집에서
|
||||
// 무엇을 어디에 놓았는지 알 수 없어, 자리표일 때는 테두리와 이름을 늘 그린다
|
||||
// (2026-09-06). 출력 때는 서버가 값으로 바꾸거나 엔티티째 빼므로 산출물에 안 실린다.
|
||||
const placeholder = (this.sourceData ?? '').includes('{{');
|
||||
// 그 밖의 그림은 **집었을 때만** 테두리를 그린다. 늘 그리면 도각의 로고 자리에 흰
|
||||
// 사각형이 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02).
|
||||
if (highlighted || selected || placeholder) {
|
||||
for (const edge of polygonToSegments(this.polygon)) {
|
||||
drawController.drawLine(edge.start, edge.end);
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
drawController.drawText(this.sourceData ?? '', this.polygon.box.center, {
|
||||
...DEFAULT_TEXT_OPTIONS,
|
||||
textAlign: 'center',
|
||||
fontSize: Math.max(this.polygon.box.height / 4, 2),
|
||||
textColor: this.lineColor,
|
||||
});
|
||||
return; // 그림이 없으니 그릴 것도 없다
|
||||
}
|
||||
|
||||
const width = this.polygon.box.width;
|
||||
const height = this.polygon.box.height;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
setActiveLayerId,
|
||||
setDesignMeta,
|
||||
setEntities,
|
||||
setFrameEditMode,
|
||||
setLayers,
|
||||
} from '../state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -36,6 +37,8 @@ interface DrawingLoadMessage {
|
||||
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
|
||||
drawing: JsonDrawingFileSerialized;
|
||||
meta?: DesignMeta | null;
|
||||
/** 도각 편집으로 실은 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다. */
|
||||
frameEdit?: boolean;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -148,6 +151,7 @@ export function registerAisloDrawingBridge() {
|
||||
resetUndoBaseline();
|
||||
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
|
||||
setDesignMeta(event.data.meta ?? null);
|
||||
setFrameEditMode(event.data.frameEdit === true);
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
// 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중
|
||||
|
||||
@@ -186,6 +186,8 @@ let snapTrackingEnabled = true;
|
||||
* 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다.
|
||||
*/
|
||||
let designMeta: DesignMeta | null = null;
|
||||
/** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */
|
||||
let frameEditMode = false;
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
@@ -256,6 +258,7 @@ export const getSnapEnabled = () => snapEnabled;
|
||||
export const getGridEnabled = () => gridEnabled;
|
||||
export const getSnapTrackingEnabled = () => snapTrackingEnabled;
|
||||
export const getDesignMeta = (): DesignMeta | null => designMeta;
|
||||
export const isFrameEditMode = (): boolean => frameEditMode;
|
||||
export const isDrawingDirty = () => drawingDirty;
|
||||
/**
|
||||
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
|
||||
@@ -486,6 +489,11 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
designMeta = newMeta;
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
|
||||
export const setFrameEditMode = (enabled: boolean) => {
|
||||
frameEditMode = enabled;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정).
|
||||
// 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은
|
||||
// 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다.
|
||||
|
||||
Reference in New Issue
Block a user