This commit is contained in:
2026-07-19 18:26:00 +09:00
parent 29a17f33ce
commit ef0dab9bc3
16 changed files with 1872 additions and 696 deletions
@@ -1,624 +1,321 @@
import { type FC, type MouseEvent, useCallback, useEffect, useState } from 'react';
import { type FC, type FormEvent, useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { COLOR_LIST } from '../App.consts';
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.ts';
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
import { importEntitiesFromJsonFile } from '../helpers/import-export-handlers/import-entities-from-json';
import { importEntitiesFromSvgFile } from '../helpers/import-export-handlers/import-entities-from-svg.ts';
import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file';
import { times } from '../helpers/times';
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
import {
getActiveLayerId,
getActiveLineColor,
getActiveLineWidth,
getActiveToolActor,
getAngleStep,
getGridEnabled,
getInputController,
getLastStateInstructions,
getLayers,
getScreenCanvasDrawController,
getSelectedEntities,
getSnapEnabled,
redo,
setActiveLayerId,
setActiveLineColor,
setActiveLineWidth,
setActiveToolActor,
setAngleStep,
setEntities,
setGridEnabled,
setLayers,
setSnapEnabled,
undo,
} from '../state';
import { Tool } from '../tools';
import { imageImportToolStateMachine } from '../tools/image-import-tool';
import { TOOL_STATE_MACHINES } from '../tools/tool.consts';
import { ActorEvent } from '../tools/tool.types';
import { Button } from './Button.tsx';
import { DropdownButton } from './DropdownButton.tsx';
import { Icon, IconName } from './Icon/Icon.tsx';
import { LayerManager } from './LayerManager.tsx';
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);
export const Toolbar: FC = () => {
const [activeToolLocal, setActiveToolLocal] = useState<Tool>(Tool.LINE);
const [angleStepLocal, setAngleStepLocal] = useState<number>(45);
const [activeLineColorLocal, setActiveLineColorLocal] = useState<string>('#FFF');
const [activeLineWidthLocal, setActiveLineWidthLocal] = useState<number>(1);
const [screenZoomLocal, setScreenZoomLocal] = useState<number>(1);
const [layersLocal, setLayersLocal] = useState<Layer[]>(getLayers());
const [activeLayerIdLocal, setActiveLayerIdLocal] = useState(getLayers()[0].id);
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'>('properties');
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 fetchStateUpdatesFromOutside = useCallback(() => {
setActiveToolLocal(getActiveToolActor()?.getSnapshot()?.context.type);
setAngleStepLocal(getAngleStep());
setActiveLineColorLocal(getActiveLineColor());
setActiveLineWidthLocal(getActiveLineWidth());
setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale());
setLayersLocal(getLayers());
const refresh = useCallback(() => {
setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE);
setZoom(getScreenCanvasDrawController().getScreenScale());
setLayersLocal([...getLayers()]);
setActiveLayerIdLocal(getActiveLayerId());
}, []);
const handleWheel = useCallback((event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault();
}
const selected = getSelectedEntities();
setSelectedCount(selected.length);
setSelectedType(
selected.length === 1 ? selected[0].getType() : selected.length ? '여러 객체' : '선택 없음'
);
setInstruction(getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.');
setSnap(getSnapEnabled());
setGrid(getGridEnabled());
setOrtho(getAngleStep() === 90);
}, []);
useEffect(() => {
window.addEventListener('wheel', handleWheel, { passive: false });
window.addEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside);
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, [refresh]);
return () => {
window.removeEventListener('wheel', handleWheel);
window.removeEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside);
};
}, [fetchStateUpdatesFromOutside, handleWheel]);
useEffect(() => {
document.documentElement.style.setProperty(
'--cad-panel-width',
panelCollapsed ? '0px' : '248px'
);
window.dispatchEvent(new Event('resize'));
}, [panelCollapsed]);
const handleToolClick = useCallback((tool: Tool) => {
getActiveToolActor()?.stop();
const newToolActor = new Actor(TOOL_STATE_MACHINES[tool]);
setActiveToolActor(newToolActor, false);
setActiveToolLocal(tool);
const activateTool = useCallback((tool: Tool) => {
const actor = new Actor(TOOL_STATE_MACHINES[tool]);
setActiveToolActor(actor);
setActiveTool(tool);
setCommandLog(`${tool} 명령 실행`);
}, []);
const handleAngleChanged = useCallback((angle: number) => {
setAngleStepLocal(angle);
setAngleStep(angle, false);
}, []);
const noopClickHandler = (evt: MouseEvent) => {
evt.stopPropagation();
const handleCommand = (event: FormEvent) => {
event.preventDefault();
const value = command.trim();
if (!value) return;
getInputController().submitText(value);
setCommandLog(`명령: ${value.toUpperCase()}`);
setCommand('');
};
const handleSetLayers = (newLayers: Layer[]) => {
setLayersLocal(newLayers);
setLayers(newLayers);
};
const handleSetActiveLayerId = (newActiveLayerId: string) => {
setActiveLayerIdLocal(newActiveLayerId);
setActiveLayerId(newActiveLayerId);
const changeZoom = (factor: number) => {
const controller = getScreenCanvasDrawController();
controller.setScreenScale(Math.max(0.05, controller.getScreenScale() * factor));
setZoom(controller.getScreenScale());
};
return (
<div className="controls top-0 left-0 flex flex-col gap-1 p-1 bg-slate-950 overscroll-y-auto">
<DropdownButton
label="Draw"
title={'Draw tools'}
iconName={IconName.Edit}
defaultOpen
dataId="dropdown-draw-tools"
>
<Button
className="w-full"
title="Select (s)"
dataId="select-button"
iconName={IconName.Direction}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.SELECT);
}}
active={activeToolLocal === Tool.SELECT}
label="Select"
/>
<Button
className="w-full"
title="Line (l)"
dataId="line-button"
iconName={IconName.Line}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.LINE);
}}
active={activeToolLocal === Tool.LINE}
label="Line"
/>
<Button
className="w-full"
title="Rectangle (r)"
dataId="rectangle-button"
iconName={IconName.Square}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.RECTANGLE);
}}
active={activeToolLocal === Tool.RECTANGLE}
label="Rectangle"
/>
<Button
className="w-full"
title="Circle (c)"
dataId="circle-button"
iconName={IconName.Circle}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.CIRCLE);
}}
active={activeToolLocal === Tool.CIRCLE}
label="Circle"
/>
<Button
className="mt-2 w-full"
title="Move"
dataId="move-button"
iconName={IconName.Expand}
iconClassname={'transform rotate-45'}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.MOVE);
}}
active={activeToolLocal === Tool.MOVE}
label="Move"
/>
<Button
className="w-full"
title="Copy"
dataId="copy-button"
iconName={IconName.Documents}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.COPY);
}}
active={activeToolLocal === Tool.COPY}
label="Copy"
/>
<Button
className="w-full"
title="Scale"
dataId="scale-button"
iconName={IconName.Scale}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.SCALE);
}}
active={activeToolLocal === Tool.SCALE}
label="Scale"
/>
<Button
className="w-full"
title="Rotate"
dataId="rotate-button"
iconName={IconName.Clockwise}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ROTATE);
}}
active={activeToolLocal === Tool.ROTATE}
label="Rotate"
/>
<Button
className="w-full"
title="Array"
dataId="array-button"
iconName={IconName.GridLayout}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ARRAY);
}}
active={activeToolLocal === Tool.ARRAY}
label="Array copy"
/>
<Button
className="w-full"
title="Create polyline lines and arcs"
dataId="pedit-button"
iconComponent={<Icon name={IconName.HomeAlt} className="rotate-270" />}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.PEDIT);
}}
active={activeToolLocal === Tool.PEDIT}
label="Polyline edit"
/>
<Button
className="mt-2 w-full"
title="Add measurements"
dataId="measurement-button"
iconName={IconName.Measurement}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.MEASUREMENT);
}}
active={activeToolLocal === Tool.MEASUREMENT}
label="Measurement"
/>
<Button
className="mt-2 w-full"
title="Delete segments"
dataId="delete-segment-button"
iconName={IconName.Crop}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ERASER);
}}
active={activeToolLocal === Tool.ERASER}
label="Eraser"
/>
</DropdownButton>
<DropdownButton dataId="layers" label="Layers" iconName={IconName.AlignTextJustify}>
<LayerManager
className="w-full"
layers={layersLocal}
activeLayerId={activeLayerIdLocal}
setLayers={handleSetLayers}
setActiveLayerId={handleSetActiveLayerId}
/>
</DropdownButton>
<Button
className="mt-2"
title="Undo (ctrl + z)"
dataId="undo-button"
iconName={IconName.ArrowLeftCircle}
onClick={() => undo()}
label="Undo"
/>
<Button
title="Redo (ctrl + shift + z)"
dataId="redo-button"
iconName={IconName.ArrowRightCircle}
onClick={() => redo()}
label="Redo"
/>
<DropdownButton
className="mt-2"
title="Align"
dataId="align-button"
label="Align"
iconName={IconName.AlignCenterHorizontal}
>
<Button
className="w-full"
title="Align left"
dataId="align-left-button"
iconName={IconName.AlignLeft}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_LEFT);
}}
active={activeToolLocal === Tool.ALIGN_LEFT}
label="Left"
/>
<Button
className="w-full"
title="Align center horizontal"
dataId="align-center-horizontal-button"
iconName={IconName.AlignCenterHorizontal}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_CENTER_HORIZONTAL);
}}
active={activeToolLocal === Tool.ALIGN_CENTER_HORIZONTAL}
label="Center"
/>
<Button
className="w-full"
title="Align right"
dataId="align-right-button"
iconName={IconName.AlignRight}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_RIGHT);
}}
active={activeToolLocal === Tool.ALIGN_RIGHT}
label="Right"
/>
<Button
className="w-full"
title="Align top"
dataId="align-top-button"
iconName={IconName.AlignTop}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_TOP);
}}
active={activeToolLocal === Tool.ALIGN_TOP}
label="Top"
/>
<Button
className="w-full"
title="Align center vertical"
dataId="align-center-vertical-button"
iconName={IconName.AlignCenterVertical}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_CENTER_VERTICAL);
}}
active={activeToolLocal === Tool.ALIGN_CENTER_VERTICAL}
label="Middle"
/>
<Button
className="w-full"
title="Align bottom"
dataId="align-bottom-button"
iconName={IconName.AlignBottom}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_BOTTOM);
}}
active={activeToolLocal === Tool.ALIGN_BOTTOM}
label="Bottom"
/>
</DropdownButton>
<DropdownButton
className="mt-2"
title="Line color"
dataId="line-color-button"
label="Line color"
iconComponent={
<div className="w-5 h-5" style={{ backgroundColor: activeLineColorLocal }} />
}
>
{COLOR_LIST.map((color) => (
<Button
key={`line-color--${color}`}
title="Change line color"
dataId={`line-color-${color}-button`}
className="w-10"
style={{ backgroundColor: color }}
active={color === activeLineColorLocal}
onClick={(evt) => {
evt.stopPropagation();
setActiveLineColor(color);
<>
<header className="cad-titlebar controls">
<div className="cad-brand">
<strong>Aislo CAD</strong>
<span>B07 상세 설계</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>
))}
</DropdownButton>
<DropdownButton
title="Line width"
dataId="line-width-button"
label="Line width"
iconComponent={
<div
className="w-5 h-0 -rotate-45 border-t-white"
style={{ borderTopWidth: `${activeLineWidthLocal}px` }}
/>
}
>
{times<number>(9).map((width: number) => {
const lineWidth = width + 1;
return (
<Button
key={`line-width--${lineWidth}`}
title="Change line width"
dataId={`line-width-${lineWidth}-button`}
label={`${String(lineWidth)}px`}
active={lineWidth === activeLineWidthLocal}
iconComponent={
<div
className="w-5 h-0 -rotate-45 border-t-white"
style={{ borderTopWidth: `${lineWidth}px` }}
/>
}
style={{ width: 'calc(50% - 2px)' }}
onClick={(evt) => {
evt.stopPropagation();
setActiveLineWidth(lineWidth);
}}
/>
);
})}
</DropdownButton>
<DropdownButton
title="Snap angles"
iconComponent={<div className="w-5 text-blue-700">{`${angleStepLocal}°`}</div>}
label="Snap angles"
dataId="angle-guide-button"
>
{[5, 15, 30, 45, 90].map((angle: number) => (
<Button
key={`angle-guide--${angle}`}
title={`Add guide every ${angle} degrees`}
dataId={`angle-guide-${angle}-button`}
label={`${angle}°`}
iconComponent={
<div
className={'w-5 h-0 border-t-2 border-t-white'}
style={{ rotate: `${-angle}deg` }}
</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);
}}
/>
}
style={{ width: 'calc(50% - 2px)' }}
onClick={(evt) => {
evt.stopPropagation();
handleAngleChanged(angle);
}}
active={angle === angleStepLocal}
/>
))}
</DropdownButton>
<DropdownButton
title="Zoom level"
iconComponent={<div className="w-5 text-blue-700">{screenZoomLocal.toFixed(1)}</div>}
label="Zoom level"
dataId="zoom-level-button"
>
{[20, 50, 75, 100, 150, 200, 400].map((zoom: number) => (
<Button
key={`zoom-level--${zoom}`}
title={`Zoom level ${zoom}%`}
dataId={`zoom-level-${zoom}-button`}
label={`${zoom.toFixed(0)}%`}
style={{ width: 'calc(30% - 2px)', padding: '8px' }}
onClick={(evt) => {
evt.stopPropagation();
getScreenCanvasDrawController().setScreenScale(zoom / 100);
setScreenZoomLocal(zoom / 100);
}}
active={zoom === screenZoomLocal}
/>
))}
<Button
key="zoom-level--fit"
title="Zoom fit screen"
dataId="zoom-level-fit-button"
label="Fit screen"
style={{ width: 'calc(60% - 2px)', padding: '8px' }}
onClick={(evt) => {
evt.stopPropagation();
)}
</>
)}
</aside>
<div className="cad-view-controls controls">
<button
type="button"
onClick={() => {
getScreenCanvasDrawController().zoomToFitScreen();
setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale());
refresh();
}}
active={false}
/>
</DropdownButton>
<Button
className="mt-2"
title="Save current drawing"
dataId="save-button"
iconName={IconName.Save}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToLocalStorage();
toast.success('Saved');
}}
label="Save drawing"
/>
<Button
className="mt-2"
title="Start a new drawing"
dataId="new-button"
iconName={IconName.FilePlus}
onClick={(evt) => {
evt.stopPropagation();
setEntities([]);
}}
label="New drawing"
/>
<DropdownButton
label="Import"
title={'Import files'}
iconName={IconName.SendUp}
dataId="dropdown-import-tools"
>
<Button
className="relative w-full"
title="Import image into the current drawing"
dataId="import-image-button"
iconName={IconName.ImageSolid}
onClick={noopClickHandler}
label="image"
title="화면에 맞춤"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.jpg,*.jpeg,*.png"
onChange={async (evt) => {
const image: HTMLImageElement = await importImageFromFile(evt.target.files?.[0]);
const imageImportActor = new Actor(imageImportToolStateMachine);
imageImportActor.start();
imageImportActor.send({
type: ActorEvent.FILE_SELECTED,
image,
});
setActiveToolActor(imageImportActor);
evt.target.files = null;
}}
/>
</Button>
<Button
className="relative w-full"
title="Load from JSON file"
dataId="json-open-button"
iconName={IconName.JavascriptSolid}
onClick={noopClickHandler}
label="JSON"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.json"
onChange={async (evt) => {
await importEntitiesFromJsonFile(evt.target.files?.[0]);
evt.target.files = null;
}}
/>
</Button>
<Button
className="relative w-full"
title="Load from SVG file"
dataId="svg-open-button"
iconName={IconName.VectorDocumentSolid}
onClick={noopClickHandler}
label="SVG"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.svg"
onChange={async (evt) => {
await importEntitiesFromSvgFile(evt.target.files?.[0]);
evt.target.files = null;
}}
/>
</Button>
</DropdownButton>
⌂
</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>
<DropdownButton
label="Export"
title={'Export file'}
iconName={IconName.SendDown}
dataId="dropdown-export-tools"
>
<Button
className="w-full"
title="Save to JSON file"
dataId="json-save-button"
iconName={IconName.JavascriptSolid}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToJsonFile();
}}
label="JSON"
/>
<Button
className="w-full"
title="Export to SVG file"
dataId="svg-export-button"
iconName={IconName.VectorDocumentSolid}
onClick={(evt) => {
evt.stopPropagation();
exportEntitiesToSvgFile();
}}
label="SVG"
/>
<Button
className="w-full"
title="Export to PNG file"
dataId="png-export-button"
iconName={IconName.ImageSolid}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToPngFile();
}}
label="PNG"
/>
</DropdownButton>
</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>
</>
);
};