refactor(B07,B08): 도면 코드 B08_DesignDetail 이동 + 수량 슬롯 B07_Quantity 확보

- B07_wf4_DesignDetail(8파일+openwebcad) -> B08_DesignDetail로 git mv (이력 보존)
- B08_wf5_Quantity -> B07_Quantity (구 수량 백업 zip 보관 폴더)
- 파생 문자열 일괄 전환: /b07-cad -> /b08-cad 정적 서빙, CAD 레이어 b07-* -> b08-*,
  postMessage aislo:b07:* -> aislo:b08:*, 패키지명 aislo-b08-cad, CSS .b08-*,
  라우트 키/슬러그(B08_DESIGN_DETAIL/b08-design-detail, B07_QUANTITY/b07-quantity)
- 상세설계 워크플로우 stage 4 -> 5 (확정/무효화 전이 3곳), 확정 완료 시 B09로 이동
- WORKFLOW_STEP_ROUTES 순서 재배열: index4=수량(B07), index5=상세설계(B08)
- [임시] B08 이동 테스트 버튼 제거, openwebcad dist 재빌드
- 주석 의미 정렬: 수량 인계 주석 B08->B07, 도면 참조 B07->B08

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 10:10:10 +09:00
co-authored by Claude Fable 5
parent 54954a05e5
commit 2a248ff742
198 changed files with 232 additions and 242 deletions
@@ -0,0 +1,81 @@
import {noop} from 'es-toolkit';
import type {CSSProperties, FC, MouseEvent, ReactNode} from 'react';
import {Icon, type IconName} from './Icon/Icon.tsx';
interface ButtonProps {
label?: string;
title?: string;
iconName?: IconName;
iconClassname?: string;
iconComponent?: ReactNode;
active?: boolean;
onClick?: (evt: MouseEvent) => void;
children?: ReactNode;
className?: string;
style?: CSSProperties;
dataId?: string;
size?: 'small' | 'regular';
type?: 'regular' | 'transparent';
left?: ReactNode;
right?: ReactNode;
}
export const Button: FC<ButtonProps> = ({
label,
title,
iconName,
iconClassname,
iconComponent,
onClick,
active = false,
children,
className,
style,
dataId,
type = 'regular',
size = 'regular',
left = null,
right = null,
}) => {
const classParts = [
'font-semibold py-4 h-10 flex flex-row justify-start w-full items-center hover:bg-blue-500 hover:text-white hover:border-transparent',
type === 'regular' ? 'bg-gray-950 text-blue-500' : '',
type === 'transparent' ? 'bg-transparent text-blue-500' : '',
active ? 'bg-blue-500 text-white border-transparent hover:bg-blue-400' : '',
size === 'regular' ? 'pl-2 pr-2 gap-2' : '',
size === 'small' ? 'pl-2 pr-2 gap-0' : '',
className || '',
];
return (
<>
<button
className={classParts.join(' ')}
style={style}
data-active={active}
data-size={size}
data-type={type}
onClick={onClick || noop}
onKeyUp={(evt) => {
if (evt.key === 'Enter' || evt.key === ' ') {
onClick?.(evt as unknown as MouseEvent);
}
}}
title={title}
data-id={dataId}
type="button"
>
{iconComponent ||
(iconName && (
<Icon
name={iconName}
className={`${iconClassname} text-blue-700 ${active ? 'text-white' : ''} ${size === 'small' ? 'w-4' : 'w-5'}`}
/>
))}
<div className="flex flex-row flex-nowrap">{left}</div>
{label && <span className="text-nowrap flex-grow text-left">{label}</span>}
{children}
<div className="flex flex-row flex-nowrap">{right}</div>
</button>
</>
);
};
@@ -0,0 +1,69 @@
import type {CSSProperties, FC, ReactNode} from 'react';
import useLocalStorageState from 'use-local-storage-state';
import {LOCAL_STORAGE_KEY} from '../App.types.ts';
import {keyboardHandler} from '../helpers/keyboard-handler.ts';
import {Button} from './Button.tsx';
import {Icon, IconName} from './Icon/Icon.tsx';
interface DropdownButtonProps {
label?: string;
title?: string;
iconName?: IconName;
iconComponent?: ReactNode;
active?: boolean;
onClick?: () => void;
className?: string;
style?: CSSProperties;
buttonStyle?: CSSProperties;
dataId: string;
children?: ReactNode;
defaultOpen?: boolean;
}
export const DropdownButton: FC<DropdownButtonProps> = ({
label,
title,
iconName,
iconComponent,
className,
style,
buttonStyle,
dataId,
children,
defaultOpen = false,
}) => {
const [isOpen, setIsOpen] = useLocalStorageState<boolean>(
`${LOCAL_STORAGE_KEY.DROPDOWN}___${dataId}`,
{ defaultValue: defaultOpen }
);
const classParts = [
'flex flex-col gap-2 relative',
className || '',
isOpen ? ' bg-slate-900' : '',
];
return (
<div className={classParts.join(' ')} style={style} data-id={dataId}>
<Button
iconName={iconName}
iconComponent={iconComponent}
label={label}
title={title}
active={isOpen}
onClick={() => setIsOpen(!isOpen)}
style={buttonStyle}
className={'w-full data-[active=true]:bg-blue-950 data-[active=true]:text-white'}
/>
<Icon name={IconName.SolidDownSmall} className={'absolute top-2.5 right-1 text-blue-700'} />
{isOpen && (
<div
className="flex flex-row flex-wrap max-w-72 gap-1 pl-1 pb-6"
onClick={() => setIsOpen(false)}
onKeyUp={keyboardHandler(() => setIsOpen(false))}
>
{children}
</div>
)}
</div>
);
};
@@ -0,0 +1,191 @@
import type {FC} from 'react';
import AlignBottomIcon from 'teenyicons/outline/align-bottom.svg?react';
import AlignCenterHorizontalIcon from 'teenyicons/outline/align-center-horizontal.svg?react';
import AlignCenterVerticalIcon from 'teenyicons/outline/align-center-vertical.svg?react';
import AlignLeftIcon from 'teenyicons/outline/align-left.svg?react';
import AlignRightIcon from 'teenyicons/outline/align-right.svg?react';
import AlignTextJustifyIcon from 'teenyicons/outline/align-text-justify.svg?react';
import AlignTopIcon from 'teenyicons/outline/align-top.svg?react';
import AntiClockwiseIcon from 'teenyicons/outline/anti-clockwise.svg?react';
import ArrowLeftCircle from 'teenyicons/outline/arrow-left-circle.svg?react';
import ArrowRightCircle from 'teenyicons/outline/arrow-right-circle.svg?react';
import CircleIcon from 'teenyicons/outline/circle.svg?react';
import ClockwiseIcon from 'teenyicons/outline/clockwise.svg?react';
import CropIcon from 'teenyicons/outline/crop.svg?react';
import DirectionIcon from 'teenyicons/outline/direction.svg?react';
import DocumentsIcon from 'teenyicons/outline/documents.svg?react';
import DownloadIcon from 'teenyicons/outline/download.svg?react';
import EditIcon from 'teenyicons/outline/edit.svg?react';
import ExpandIcon from 'teenyicons/outline/expand.svg?react';
import EyeClosedIcon from 'teenyicons/outline/eye-closed.svg?react';
import EyeIcon from 'teenyicons/outline/eye.svg?react';
import FilePlusIcon from 'teenyicons/outline/file-plus.svg?react';
import FolderPlusIcon from 'teenyicons/outline/folder-plus.svg?react';
import FolderTickIcon from 'teenyicons/outline/folder-tick.svg?react';
import FolderXIcon from 'teenyicons/outline/folder-x.svg?react';
import FolderIcon from 'teenyicons/outline/folder.svg?react';
import GithubIcon from 'teenyicons/outline/github.svg?react';
import GridLayoutIcon from 'teenyicons/outline/grid-layout.svg?react';
import ImageIcon from 'teenyicons/outline/image.svg?react';
import JavascriptIcon from 'teenyicons/outline/javascript.svg?react';
import LayersDifferenceIcon from 'teenyicons/outline/layers-difference.svg?react';
import LineIcon from 'teenyicons/outline/line.svg?react';
import LockIcon from 'teenyicons/outline/lock.svg?react';
import PdfIcon from 'teenyicons/outline/pdf.svg?react';
import PngIcon from 'teenyicons/outline/png.svg?react';
import SaveIcon from 'teenyicons/outline/save.svg?react';
import SendDownIcon from 'teenyicons/outline/send-down.svg?react';
import SendUpIcon from 'teenyicons/outline/send-up.svg?react';
import SquareIcon from 'teenyicons/outline/square.svg?react';
import SvgIcon from 'teenyicons/outline/svg.svg?react';
import UnlockIcon from 'teenyicons/outline/unlock.svg?react';
import HomeAltIcon from 'teenyicons/outline/home-alt.svg?react';
import VectorDocumentIcon from 'teenyicons/outline/vector-document.svg?react';
import SolidDownSmallIcon from 'teenyicons/solid/down-small.svg?react';
import SolidDownIcon from 'teenyicons/solid/down.svg?react';
import ImageSoldIcon from 'teenyicons/solid/image.svg?react';
import JavascriptSolidIcon from 'teenyicons/solid/javascript.svg?react';
import PdfSolidIcon from 'teenyicons/solid/pdf.svg?react';
import SolidUpSmallIcon from 'teenyicons/solid/up-small.svg?react';
import SolidUpIcon from 'teenyicons/solid/up.svg?react';
import VectorDocumentSolidIcon from 'teenyicons/solid/vector-document.svg?react';
import MeasurementIcon from './custom-icons/measurement.svg?react';
import ScaleIcon from './custom-icons/scale.svg?react'; // https://icon-sets.iconify.design/teenyicons
// https://icon-sets.iconify.design/teenyicons
enum IconName {
// Outline icons
Line = 'Line',
Square = 'Square',
Circle = 'Circle',
Direction = 'Direction',
VectorDocument = 'VectorDocument',
VectorDocumentSolid = 'VectorDocumentSolid',
LayersDifference = 'LayersDifference',
AntiClockwise = 'AntiClockwise',
Clockwise = 'Clockwise',
Github = 'Github',
Crop = 'Crop',
Folder = 'Folder',
FolderTick = 'FolderTick',
Save = 'Save',
Svg = 'Svg',
Pdf = 'Pdf',
PdfSolid = 'PdfSolid',
Png = 'Png',
Javascript = 'Javascript',
JavascriptSolid = 'JavascriptSolid',
Expand = 'Expand',
Image = 'Image',
ImageSolid = 'ImageSolid',
ArrowLeftCircle = 'ArrowLeftCircle',
ArrowRightCircle = 'ArrowRightCircle',
Measurement = 'Measurement',
FilePlus = 'FilePlus',
Edit = 'Edit',
SendUp = 'SendUp',
SendDown = 'SendDown',
AlignLeft = 'AlignLeft',
AlignRight = 'AlignRight',
AlignCenterHorizontal = 'AlignCenterHorizontal',
AlignTop = 'AlignTop',
AlignBottom = 'AlignBottom',
AlignCenterVertical = 'AlignCenterVertical',
Documents = 'Documents',
Download = 'Download',
FolderX = 'FolderX',
FolderPlus = 'FolderPlus',
AlignTextJustify = 'AlignTextJustify',
Eye = 'Eye',
EyeClosed = 'EyeClosed',
Lock = 'Lock',
Unlock = 'Unlock',
GridLayout = 'GridLayout',
HomeAlt = 'HomeAlt',
// Solid icons
SolidDown = 'SolidDown',
SolidUp = 'SolidUp',
SolidUpSmall = 'SolidUpSmall',
SolidDownSmall = 'SolidDownSmall',
// Custom icons
Scale = 'Scale',
}
const icons: Record<IconName, FC> = {
// Outline icons
[IconName.Line]: LineIcon,
[IconName.Square]: SquareIcon,
[IconName.Circle]: CircleIcon,
[IconName.Direction]: DirectionIcon,
[IconName.VectorDocument]: VectorDocumentIcon,
[IconName.VectorDocumentSolid]: VectorDocumentSolidIcon,
[IconName.LayersDifference]: LayersDifferenceIcon,
[IconName.AntiClockwise]: AntiClockwiseIcon,
[IconName.Clockwise]: ClockwiseIcon,
[IconName.Github]: GithubIcon,
[IconName.Crop]: CropIcon,
[IconName.Folder]: FolderIcon,
[IconName.FolderTick]: FolderTickIcon,
[IconName.Save]: SaveIcon,
[IconName.Svg]: SvgIcon,
[IconName.Pdf]: PdfIcon,
[IconName.PdfSolid]: PdfSolidIcon,
[IconName.Png]: PngIcon,
[IconName.Javascript]: JavascriptIcon,
[IconName.JavascriptSolid]: JavascriptSolidIcon,
[IconName.Expand]: ExpandIcon,
[IconName.Image]: ImageIcon,
[IconName.ImageSolid]: ImageSoldIcon,
[IconName.ArrowLeftCircle]: ArrowLeftCircle,
[IconName.ArrowRightCircle]: ArrowRightCircle,
[IconName.Measurement]: MeasurementIcon,
[IconName.FilePlus]: FilePlusIcon,
[IconName.Edit]: EditIcon,
[IconName.SendUp]: SendUpIcon,
[IconName.SendDown]: SendDownIcon,
[IconName.AlignLeft]: AlignLeftIcon,
[IconName.AlignRight]: AlignRightIcon,
[IconName.AlignCenterHorizontal]: AlignCenterHorizontalIcon,
[IconName.AlignTop]: AlignTopIcon,
[IconName.AlignBottom]: AlignBottomIcon,
[IconName.AlignCenterVertical]: AlignCenterVerticalIcon,
[IconName.Documents]: DocumentsIcon,
[IconName.Download]: DownloadIcon,
[IconName.FolderX]: FolderXIcon,
[IconName.FolderPlus]: FolderPlusIcon,
[IconName.AlignTextJustify]: AlignTextJustifyIcon,
[IconName.Eye]: EyeIcon,
[IconName.EyeClosed]: EyeClosedIcon,
[IconName.Lock]: LockIcon,
[IconName.Unlock]: UnlockIcon,
[IconName.GridLayout]: GridLayoutIcon,
[IconName.HomeAlt]: HomeAltIcon,
// Solid icons
[IconName.SolidDown]: SolidDownIcon,
[IconName.SolidUp]: SolidUpIcon,
[IconName.SolidUpSmall]: SolidUpSmallIcon,
[IconName.SolidDownSmall]: SolidDownSmallIcon,
// Custom icons
[IconName.Scale]: ScaleIcon,
};
export { IconName };
interface IconProps {
name: IconName;
className?: string;
}
export const Icon: FC<IconProps> = ({ name, className }) => {
const CurrentIcon = icons[name];
return (
<div className={`icon w-5${className ? ` ${className}` : ''}`}>
<CurrentIcon />
</div>
);
};
@@ -0,0 +1,4 @@
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 7.5l-4 4m4-4l-4-4m4 4H3M1.5 1v13" stroke="currentColor"/>
<path d="M1.5 7.5l4-4m-4 4l4 4m-4-4H12m1.5 6.5V1" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

@@ -0,0 +1,5 @@
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5.5h-12a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1v-12a1 1 0 00-1-1z" stroke="currentColor"/>
<path d="M 9.159 5.175 L 1.166 5.175 C 0.799 5.175 0.5 5.472 0.5 5.841 L 0.5 13.834 C 0.5 14.203 0.799 14.5 1.166 14.5 L 9.159 14.5 C 9.528 14.5 9.825 14.203 9.825 13.834 L 9.825 5.841 C 9.825 5.472 9.528 5.175 9.159 5.175 Z"
stroke="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,163 @@
import type {FC, MouseEvent} from 'react';
import type {Layer} from '../App.types.ts';
import {getNewLayer} from '../helpers/get-new-layer.ts';
import {getActiveLayerId, getEntities, getLayers, getSelectedEntities, setEntities, setSelectedEntityIds,} from '../state.ts';
import {Button} from './Button';
import {IconName} from './Icon/Icon.tsx';
interface LayerManagerProps {
layers: Layer[];
setLayers: (layers: Layer[]) => void;
activeLayerId: string;
setActiveLayerId: (layerId: string) => void;
className?: string;
}
export const LayerManager: FC<LayerManagerProps> = ({
layers,
setLayers,
activeLayerId,
setActiveLayerId,
className,
}) => {
const handleLayerClick = (evt: MouseEvent, layerId: string) => {
evt.stopPropagation();
setActiveLayerId(layerId);
};
const handleSelectEntitiesOnLayer = (evt: MouseEvent, layerId: string): void => {
evt.stopPropagation();
const entitiesOnLayer = getEntities().filter((entity) => entity.layerId === layerId);
setSelectedEntityIds(entitiesOnLayer.map((entity) => entity.id));
};
const handleAssignSelectionToLayer = (evt: MouseEvent, layerId: string): void => {
evt.stopPropagation();
const selectedEntities = getSelectedEntities();
for (const entity of selectedEntities) {
entity.layerId = layerId;
}
console.info(`Assigned ${selectedEntities.length} entities to layer`);
};
const handleDeleteLayer = (evt: MouseEvent, layerId: string): void => {
evt.stopPropagation();
const entitiesNotOnLayer = getEntities().filter((entity) => entity.layerId !== layerId);
setEntities(entitiesNotOnLayer);
setLayers(getLayers().filter((layer) => layer.id !== layerId));
if (getActiveLayerId() === layerId) {
setActiveLayerId(getLayers()[0].id);
}
};
const handleShowHideLayer = (evt: MouseEvent, layerId: string): void => {
evt.stopPropagation();
const layer: Layer | undefined = layers.find((layer) => layer.id === layerId);
if (!layer) {
return;
}
layer.isVisible = !layer.isVisible;
setLayers([...layers]);
if (getActiveLayerId() === layerId && !layer.isVisible) {
setActiveLayerId(getLayers()[0].id);
}
};
const handleLockUnlockLayer = (evt: MouseEvent, layerId: string): void => {
evt.stopPropagation();
const layer: Layer | undefined = layers.find((layer) => layer.id === layerId);
if (!layer) {
return;
}
layer.isLocked = !layer.isLocked;
setLayers([...layers]);
if (getActiveLayerId() === layerId && layer.isLocked) {
setActiveLayerId(getLayers()[0].id);
}
};
const handleCreateNewLayer = (evt: MouseEvent): void => {
evt.stopPropagation();
const newLayer: Layer = getNewLayer();
setLayers([...getLayers(), newLayer]);
setActiveLayerId(newLayer.id);
};
return (
<div className={`layer-manager flex flex-col ${className}`}>
<div className="layers-wrapper flex flex-col gap-2">
{layers.map((layer) => (
<div className="layer flex flex-row relative" key={`layer-${layer.id}`}>
<Button
label={layer.name}
title="Set this layer as active"
active={activeLayerId === layer.id}
onClick={(evt) => handleLayerClick(evt, layer.id)}
className="data-[active=true]:text-white flex-grow"
left={
<>
<Button
iconName={layer.isVisible ? IconName.Eye : IconName.EyeClosed}
title="Show/hide layer content"
onClick={(evt) => handleShowHideLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300 -ml-1"
type="transparent"
active={activeLayerId === layer.id}
/>
<Button
iconName={layer.isLocked ? IconName.Lock : IconName.Unlock}
title="Lock/Unlock layer content"
onClick={(evt) => handleLockUnlockLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
type="transparent"
active={activeLayerId === layer.id}
/>
</>
}
right={
<>
<Button
iconName={IconName.Direction}
title="Select entities on this layer"
onClick={(evt) => handleSelectEntitiesOnLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
type="transparent"
active={activeLayerId === layer.id}
/>
<Button
iconName={IconName.Download}
title="Assign current selection to layer"
onClick={(evt) => handleAssignSelectionToLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
type="transparent"
active={activeLayerId === layer.id}
/>
<Button
iconName={IconName.FolderX}
title="delete layer and content"
onClick={(evt) => handleDeleteLayer(evt, layer.id)}
size="small"
className="w-10 hover:bg-blue-300"
type="transparent"
active={activeLayerId === layer.id}
/>
</>
}
/>
</div>
))}
</div>
<Button
label="New layer"
iconName={IconName.FolderPlus}
title="Create a new layer"
onClick={(evt) => handleCreateNewLayer(evt)}
className="w-full mt-2"
/>
</div>
);
};
@@ -0,0 +1,57 @@
import { type FC, useCallback, useEffect, useState } from 'react';
import { HtmlEvent } from '../App.types';
import { requestDrawingNavigation } from '../integration/aislo-drawing-bridge';
import { getDesignMeta } from '../state';
/**
* 도면 내비게이션 바 (CAD 화면 하단 중심).
* 수량 산출표는 HTML 테이블 대신 도면 자체의 CAD 테이블 레이어(b08-cross-table)로
* 렌더되므로, 여기서는 제목·측점정보·확정상태·이전/다음 이동만 담당한다.
*/
export const QuantityPanel: FC = () => {
const [meta, setMeta] = useState(getDesignMeta());
const refresh = useCallback(() => setMeta(getDesignMeta()), []);
useEffect(() => {
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, [refresh]);
if (!meta) return null;
return (
<section className="cad-qtable controls" data-collapsed="true">
<header className="cad-qtable__header">
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasPrev}
title="이전 도면"
onClick={() => requestDrawingNavigation('prev')}
>
‹
</button>
<div className="cad-qtable__title">
<strong>{meta.title}</strong>
{meta.info && <span>{meta.info}</span>}
<em
className="cad-qtable__status"
data-confirmed={meta.confirmed}
title={meta.confirmed ? '설계 확정됨' : '미확정'}
>
{meta.confirmed ? '확정' : '미확정'}
</em>
</div>
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasNext}
title="다음 도면"
onClick={() => requestDrawingNavigation('next')}
>
›
</button>
</header>
</section>
);
};
@@ -0,0 +1,483 @@
import { type FC, type FormEvent, useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { HtmlEvent, type Layer } from '../App.types';
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
import type { Entity } from '../entities/Entity';
import { EntityName } from '../entities/Entity';
import { TextEntity } from '../entities/TextEntity';
import {
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
getActiveTextStyle,
getActiveToolActor,
getAngleStep,
getEntities,
getGridEnabled,
getInputController,
getLastStateInstructions,
getLayers,
getScreenCanvasDrawController,
getSelectedEntities,
getSnapEnabled,
redo,
setActiveLayerId,
setActiveLineColor,
setActiveLineDash,
setActiveLineWidth,
setActiveTextStyle,
setActiveToolActor,
setAngleStep,
setEntities,
setGridEnabled,
setLayers,
setSnapEnabled,
undo,
} from '../state';
import { Tool } from '../tools';
import { TOOL_STATE_MACHINES } from '../tools/tool.consts';
import { LayerManager } from './LayerManager';
interface RibbonTool {
label: string;
shortcut?: string;
tool?: Tool;
glyph: string;
disabled?: boolean;
}
const RIBBON_GROUPS: { label: string; tools: RibbonTool[] }[] = [
{
label: '그리기',
tools: [
{ label: '선', shortcut: 'L', tool: Tool.LINE, glyph: '╱' },
{ label: '폴리선', shortcut: 'PE', tool: Tool.PEDIT, glyph: '⌁' },
{ label: '원', shortcut: 'C', tool: Tool.CIRCLE, glyph: '○' },
{ label: '사각형', shortcut: 'R', tool: Tool.RECTANGLE, glyph: '□' },
{ label: '호', glyph: '◜', disabled: true },
],
},
{
label: '수정',
tools: [
{ label: '선택', shortcut: 'S', tool: Tool.SELECT, glyph: '↖' },
{ label: '이동', tool: Tool.MOVE, glyph: '✥' },
{ label: '복사', tool: Tool.COPY, glyph: '▣' },
{ label: '회전', tool: Tool.ROTATE, glyph: '↻' },
{ label: '자르기', tool: Tool.ERASER, glyph: '⌫' },
{ label: '간격띄우기', glyph: '⇶', disabled: true },
],
},
{
label: '주석',
tools: [
{ label: '치수', tool: Tool.MEASUREMENT, glyph: '↔' },
{ label: '문자', glyph: 'A', disabled: true },
],
},
];
const COMMANDS = Object.values(Tool);
const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
{ value: 'solid', label: '실선', dash: undefined },
{ value: 'dashed', label: '파선', dash: [10, 5] },
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
{ value: 'dotted', label: '점선', dash: [2, 4] },
];
const LINE_WIDTHS = [1, 2, 3, 4, 5];
const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace'];
const dashToLineType = (dash: number[] | undefined): string =>
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
export const Toolbar: FC = () => {
const [activeTool, setActiveTool] = useState<Tool>(Tool.LINE);
const [zoom, setZoom] = useState(1);
const [layers, setLayersLocal] = useState<Layer[]>(getLayers());
const [activeLayerId, setActiveLayerIdLocal] = useState(getActiveLayerId());
const [selectedCount, setSelectedCount] = useState(0);
const [selectedType, setSelectedType] = useState('선택 없음');
const [instruction, setInstruction] = useState('명령을 입력하거나 도구를 선택하십시오.');
const [panelTab, setPanelTab] = useState<'properties' | 'layers'>('layers');
const [panelCollapsed, setPanelCollapsed] = useState(false);
const [snap, setSnap] = useState(getSnapEnabled());
const [grid, setGrid] = useState(getGridEnabled());
const [ortho, setOrtho] = useState(getAngleStep() === 90);
const [command, setCommand] = useState('');
const [commandLog, setCommandLog] = useState('준비');
const [lineColor, setLineColorLocal] = useState(getActiveLineColor());
const [lineWidth, setLineWidthLocal] = useState(getActiveLineWidth());
const [lineType, setLineTypeLocal] = useState(dashToLineType(getActiveLineDash()));
const [textStyle, setTextStyleLocal] = useState(getActiveTextStyle());
const refresh = useCallback(() => {
setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE);
setZoom(getScreenCanvasDrawController().getScreenScale());
setLayersLocal([...getLayers()]);
setActiveLayerIdLocal(getActiveLayerId());
const selected = getSelectedEntities();
setSelectedCount(selected.length);
setSelectedType(
selected.length === 1 ? selected[0].getType() : selected.length ? '여러 객체' : '선택 없음'
);
setInstruction(getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.');
setSnap(getSnapEnabled());
setGrid(getGridEnabled());
setOrtho(getAngleStep() === 90);
setLineColorLocal(getActiveLineColor());
setLineWidthLocal(getActiveLineWidth());
setLineTypeLocal(dashToLineType(getActiveLineDash()));
setTextStyleLocal({ ...getActiveTextStyle() });
}, []);
useEffect(() => {
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, [refresh]);
useEffect(() => {
document.documentElement.style.setProperty(
'--cad-panel-width',
panelCollapsed ? '0px' : '248px'
);
window.dispatchEvent(new Event('resize'));
}, [panelCollapsed]);
const activateTool = useCallback((tool: Tool) => {
const actor = new Actor(TOOL_STATE_MACHINES[tool]);
setActiveToolActor(actor);
setActiveTool(tool);
setCommandLog(`${tool} 명령 실행`);
}, []);
const handleCommand = (event: FormEvent) => {
event.preventDefault();
const value = command.trim();
if (!value) return;
getInputController().submitText(value);
setCommandLog(`명령: ${value.toUpperCase()}`);
setCommand('');
};
const changeZoom = (factor: number) => {
const controller = getScreenCanvasDrawController();
controller.setScreenScale(Math.max(0.05, controller.getScreenScale() * factor));
setZoom(controller.getScreenScale());
};
/** 선택 객체가 있으면 스타일을 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
const applyToSelection = useCallback((mutate: (entity: Entity) => void): boolean => {
const selected = getSelectedEntities();
if (!selected.length) return false;
for (const entity of selected) {
mutate(entity);
}
setEntities([...getEntities()], true);
return true;
}, []);
const handleLineColor = (color: string) => {
setActiveLineColor(color);
setLineColorLocal(color);
if (applyToSelection((entity) => (entity.lineColor = color))) {
setCommandLog('선택 객체 색상 변경');
}
};
const handleLineWidth = (width: number) => {
setActiveLineWidth(width);
setLineWidthLocal(width);
if (applyToSelection((entity) => (entity.lineWidth = width))) {
setCommandLog('선택 객체 선굵기 변경');
}
};
const handleLineType = (value: string) => {
const dash = LINE_TYPES.find((type) => type.value === value)?.dash;
setActiveLineDash(dash ? [...dash] : undefined);
setLineTypeLocal(value);
if (applyToSelection((entity) => (entity.lineDash = dash ? [...dash] : undefined))) {
setCommandLog('선택 객체 선종류 변경');
}
};
const handleTextStyle = (patch: Partial<typeof textStyle>) => {
setActiveTextStyle(patch);
setTextStyleLocal((previous) => ({ ...previous, ...patch }));
const applied = applyToSelection((entity) => {
if (entity.getType() === EntityName.Text) {
(entity as TextEntity).setTextOptions(patch);
}
});
if (applied) {
setCommandLog('선택 문자 스타일 변경');
}
};
return (
<>
<header className="cad-titlebar controls">
<div className="cad-brand">
<strong>Aislo CAD</strong>
<span>B08 상세 설계</span>
</div>
<div className="cad-file-state">
<span className="cad-file-state__dot" />
현재 도면 · 저장됨
</div>
<div className="cad-title-actions">
<button type="button" onClick={() => undo()} title="실행 취소 (Ctrl+Z)">
↶
</button>
<button type="button" onClick={() => redo()} title="다시 실행 (Ctrl+Y)">
↷
</button>
<button
type="button"
onClick={async () => {
await exportEntitiesToLocalStorage();
toast.success('도면을 저장했습니다.');
}}
>
저장
</button>
<button type="button" onClick={() => exportEntitiesToJsonFile()}>
내보내기
</button>
</div>
</header>
<nav className="cad-ribbon controls" aria-label="CAD 도구 리본">
{RIBBON_GROUPS.map((group) => (
<section className="cad-ribbon-group" key={group.label}>
<div className="cad-ribbon-tools">
{group.tools.map((item) => (
<button
type="button"
key={item.label}
className="cad-tool"
data-active={item.tool === activeTool}
disabled={item.disabled}
title={
item.disabled
? `${item.label} 도구는 후속 구현 예정입니다.`
: `${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`
}
onClick={() => item.tool && activateTool(item.tool)}
>
<span className="cad-tool__glyph">{item.glyph}</span>
<span>{item.label}</span>
</button>
))}
</div>
<span className="cad-ribbon-group__label">{group.label}</span>
</section>
))}
<section className="cad-ribbon-group">
<div className="cad-ribbon-tools cad-ribbon-props">
<label className="cad-prop" title="선 색상">
<span>색상</span>
<input
type="color"
value={lineColor}
onChange={(event) => handleLineColor(event.target.value)}
/>
</label>
<label className="cad-prop" title="선 굵기">
<span>굵기</span>
<select
value={lineWidth}
onChange={(event) => handleLineWidth(Number(event.target.value))}
>
{LINE_WIDTHS.map((width) => (
<option key={width} value={width}>
{width}px
</option>
))}
</select>
</label>
<label className="cad-prop" title="선 종류">
<span>선종류</span>
<select value={lineType} onChange={(event) => handleLineType(event.target.value)}>
{LINE_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</label>
</div>
<span className="cad-ribbon-group__label">특성</span>
</section>
<section className="cad-ribbon-group">
<div className="cad-ribbon-tools cad-ribbon-props">
<label className="cad-prop" title="폰트">
<span>폰트</span>
<select
value={textStyle.fontFamily}
onChange={(event) => handleTextStyle({ fontFamily: event.target.value })}
>
{FONT_FAMILIES.map((family) => (
<option key={family} value={family}>
{family}
</option>
))}
</select>
</label>
<label className="cad-prop" title="문자 크기">
<span>크기</span>
<input
type="number"
min={4}
max={120}
value={textStyle.fontSize}
onChange={(event) => {
const size = Number(event.target.value);
if (Number.isFinite(size) && size > 0) {
handleTextStyle({ fontSize: size });
}
}}
/>
</label>
<label className="cad-prop" title="문자 색상">
<span>색상</span>
<input
type="color"
value={textStyle.textColor}
onChange={(event) => handleTextStyle({ textColor: event.target.value })}
/>
</label>
</div>
<span className="cad-ribbon-group__label">문자</span>
</section>
</nav>
<aside className="cad-inspector controls" data-collapsed={panelCollapsed}>
<button
className="cad-inspector__collapse"
type="button"
onClick={() => setPanelCollapsed((value) => !value)}
title={panelCollapsed ? '패널 펼치기' : '패널 접기'}
>
{panelCollapsed ? '›' : '‹'}
</button>
{!panelCollapsed && (
<>
<div className="cad-inspector-tabs">
<button
type="button"
data-active={panelTab === 'properties'}
onClick={() => setPanelTab('properties')}
>
특성
</button>
<button
type="button"
data-active={panelTab === 'layers'}
onClick={() => setPanelTab('layers')}
>
도면층
</button>
</div>
{panelTab === 'properties' ? (
<div className="cad-properties">
<h2>{selectedType}</h2>
<dl>
<div>
<dt>선택 객체</dt>
<dd>{selectedCount}</dd>
</div>
<div>
<dt>현재 도구</dt>
<dd>{activeTool}</dd>
</div>
<div>
<dt>현재 도면층</dt>
<dd>{layers.find((layer) => layer.id === activeLayerId)?.name ?? '-'}</dd>
</div>
</dl>
</div>
) : (
<LayerManager
className="cad-layer-manager"
layers={layers}
activeLayerId={activeLayerId}
setLayers={(next) => {
setLayersLocal(next);
setLayers(next);
}}
setActiveLayerId={(id) => {
setActiveLayerIdLocal(id);
setActiveLayerId(id);
}}
/>
)}
</>
)}
</aside>
<div className="cad-view-controls controls">
<button
type="button"
className="cad-view-controls__fit"
onClick={() => {
getScreenCanvasDrawController().zoomToFitScreen();
refresh();
}}
title="전체 보기 (도면을 화면 중심에 맞춤)"
>
⛶
</button>
<button type="button" onClick={() => changeZoom(1.2)} title="확대">
+
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => changeZoom(0.8)} title="축소">
-
</button>
</div>
<section className="cad-command-area controls">
<div className="cad-command-prompt">
<span>{commandLog}</span>
<strong>{instruction}</strong>
</div>
<form onSubmit={handleCommand}>
<label htmlFor="cad-command">명령:</label>
<input
id="cad-command"
list="cad-command-list"
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="명령 입력 (예: LINE, MOVE, CIRCLE)"
autoComplete="off"
/>
<datalist id="cad-command-list">
{COMMANDS.map((item) => (
<option value={item} key={item} />
))}
</datalist>
</form>
</section>
<footer className="cad-statusbar controls">
<button type="button" data-active={snap} onClick={() => setSnapEnabled(!snap)}>
OSNAP
</button>
<button type="button" data-active={ortho} onClick={() => setAngleStep(ortho ? 45 : 90)}>
직교
</button>
<button type="button" data-active={grid} onClick={() => setGridEnabled(!grid)}>
그리드
</button>
<span className="cad-statusbar__hint">휠: 줌 · 휠 드래그: 팬 · Esc: 취소</span>
</footer>
</>
);
};