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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user