- 부품 상자 CSS · 단추 기본 꼴을 CAD iframe 에 실음(integration/ui-kit.ts) · 자리 · 폭은 cad-* 그대로 - 상태막대 = ui-toggle--chip 22 · 특성/레이어 = ui-tabs 36 · ↶↷ · 리본 접기 · 탐색 막대 = ui-icon-btn 20 · 32 - 빠른 특성 · 블록 라이브러리 · 자리표 = ui-float-panel · 리본 넘침 · 스냅 · 명령 자동완성 = ui-ctx-menu - window.confirm · window.prompt → askConfirm · askText · 빈 안내 = ui-empty 작은 꼴 - 높이 단: 리본 칸 19→18 · 명령 칸 26→28 · 레이어 줄 40→42 · 문서 탭 25→24 · 도각 편집 단추 24 - 마우스 바탕 = --color-hover · 켜짐 = --color-accent-soft · 도면 그림 색(--draw-*) 안 건드림 - 안 쓰는 DropdownButton 지움(쓰는 곳 0 · grep) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
7.6 KiB
TypeScript
226 lines
7.6 KiB
TypeScript
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,
|
|
getFrameFields,
|
|
getScreenCanvasDrawController,
|
|
getSelectedEntities,
|
|
isFrameEditMode,
|
|
setEntities,
|
|
} from '../state';
|
|
|
|
/**
|
|
* 도각 자리표 패널 — 프로그램 값이 들어갈 자리를 사용자가 직접 놓는다 (2026-09-06 사용자 확정).
|
|
*
|
|
* 값을 알아맞히는 규칙은 만들지 않는다. 여기서 놓은 `{{키}}` 토큰을 도면 출력 때 서버의
|
|
* 치환 엔진이 채운다. 자리표를 안 놓은 값은 빈칸으로 남는다.
|
|
*
|
|
* 도각 편집으로 도면을 실었을 때만 뜬다. 놓은 자리표는 화면 한가운데에 서고, 그 뒤
|
|
* 캐드의 이동·크기 도구로 자리를 잡는다.
|
|
*/
|
|
|
|
/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */
|
|
const TEXT_TOKENS = [
|
|
'도면명',
|
|
'도면번호',
|
|
'공사명',
|
|
'위치',
|
|
'시행청',
|
|
'용역회사',
|
|
'연도기번',
|
|
'사업량',
|
|
'과업책임자',
|
|
'분야별책임자',
|
|
'설계자',
|
|
'설계일자',
|
|
'축척_A1',
|
|
'축척_A3',
|
|
] as const;
|
|
|
|
/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 도면에서 그림째 빠진다. */
|
|
const IMAGE_TOKENS = ['회사로고', '과업책임자서명', '분야별책임자서명', '설계자서명'] as const;
|
|
|
|
const TEXT_SIZE_MM = 5;
|
|
// 자리표가 차지하는 칸 기본 크기(mm). 놓은 뒤 아래 「칸 크기」에서 고친다.
|
|
const TEXT_BOX_WIDTH_MM = 60;
|
|
const TEXT_BOX_HEIGHT_MM = 10;
|
|
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',
|
|
boxWidth: TEXT_BOX_WIDTH_MM,
|
|
boxHeight: TEXT_BOX_HEIGHT_MM,
|
|
});
|
|
// 편집 중에는 실제 값을 보여 준다 — 저장값은 토큰 그대로다.
|
|
entity.previewLabel = getFrameFields()[token] ?? null;
|
|
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]);
|
|
const preview = getFrameFields()[token];
|
|
if (preview) entity.setPreviewImage(preview);
|
|
setEntities([...getEntities(), entity], true);
|
|
}
|
|
|
|
/** 지금 고른 자리표 하나 — 칸 크기를 고칠 대상. 없으면 null. */
|
|
function selectedPlaceholder(): TextEntity | ImageEntity | null {
|
|
const selected = getSelectedEntities();
|
|
if (selected.length !== 1) return null;
|
|
const entity = selected[0];
|
|
if (entity instanceof TextEntity && entity.getLabel().includes('{{')) return entity;
|
|
if (entity instanceof ImageEntity && entity.isPlaceholder()) return entity;
|
|
return null;
|
|
}
|
|
|
|
function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: number } {
|
|
const box = entity.getBoundingBox();
|
|
return { width: Math.round(box.width * 10) / 10, height: Math.round(box.height * 10) / 10 };
|
|
}
|
|
|
|
export const FramePlaceholderPanel: FC = () => {
|
|
const [visible, setVisible] = useState(isFrameEditMode());
|
|
// 접으면 그림 오른쪽 아래를 덮지 않는다 — 머리 줄만 남는다.
|
|
const [folded, setFolded] = useState(false);
|
|
const [picked, setPicked] = useState<TextEntity | ImageEntity | null>(null);
|
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
|
|
const refresh = useCallback(() => {
|
|
setVisible(isFrameEditMode());
|
|
const entity = selectedPlaceholder();
|
|
setPicked(entity);
|
|
if (entity) setSize(boxSizeOf(entity));
|
|
}, []);
|
|
useEffect(() => {
|
|
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
|
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
|
}, [refresh]);
|
|
|
|
const applySize = (width: number, height: number): void => {
|
|
if (!picked) return;
|
|
setSize({ width, height });
|
|
picked.setBoxSize(width, height);
|
|
setEntities([...getEntities()], true);
|
|
};
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<section className="ui-float-panel cad-frame-tokens controls">
|
|
<header className="ui-float-panel__head cad-frame-tokens__header">
|
|
<span className="ui-float-panel__title">자리표 놓기</span>
|
|
<button
|
|
type="button"
|
|
className="ui-icon-btn ui-icon-btn--ghost"
|
|
data-size="24"
|
|
aria-expanded={!folded}
|
|
aria-label={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
|
|
title={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
|
|
onClick={() => setFolded(!folded)}
|
|
>
|
|
{folded ? '▴' : '▾'}
|
|
</button>
|
|
</header>
|
|
{!folded && (
|
|
<div className="ui-float-panel__body cad-frame-tokens__body">
|
|
<p className="ui-text ui-text--muted">
|
|
누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다
|
|
</p>
|
|
<div className="cad-frame-tokens__group">
|
|
<span className="ui-text ui-text--muted cad-frame-tokens__title">글자</span>
|
|
{TEXT_TOKENS.map((token) => (
|
|
<button
|
|
key={token}
|
|
type="button"
|
|
className="ui-btn ui-btn--ghost"
|
|
data-size="22"
|
|
onClick={() => addTextPlaceholder(token)}
|
|
>
|
|
{token}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="cad-frame-tokens__group">
|
|
<span className="ui-text ui-text--muted cad-frame-tokens__title">
|
|
그림 (칸에 비율 그대로 들어감)
|
|
</span>
|
|
{IMAGE_TOKENS.map((token) => (
|
|
<button
|
|
key={token}
|
|
type="button"
|
|
className="ui-btn ui-btn--ghost"
|
|
data-size="22"
|
|
onClick={() => void addImagePlaceholder(token)}
|
|
>
|
|
{token}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{picked && (
|
|
<div className="cad-frame-tokens__group">
|
|
<span className="ui-text ui-text--muted cad-frame-tokens__title">
|
|
고른 자리표 칸 크기 (mm)
|
|
</span>
|
|
<label className="ui-text ui-text--muted cad-frame-tokens__size">
|
|
가로
|
|
<input
|
|
type="number"
|
|
className="ui-input"
|
|
data-size="24"
|
|
min={1}
|
|
value={size.width}
|
|
onChange={(event) => applySize(Number(event.target.value), size.height)}
|
|
/>
|
|
</label>
|
|
<label className="ui-text ui-text--muted cad-frame-tokens__size">
|
|
세로
|
|
<input
|
|
type="number"
|
|
className="ui-input"
|
|
data-size="24"
|
|
min={1}
|
|
value={size.height}
|
|
onChange={(event) => applySize(size.width, Number(event.target.value))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|