feat(B07): CAD를 AutoCAD 명령 체계로 재구성한다 (조사표 1·2·3·5절)

저장소 사고로 잃은 4개 커밋(38eb7cd4·b8a11756·281528bf·f60b488d)의 작업물을
하나로 다시 담았다. 내용은 동일하다.

■ 구조
- commands/: 명령 정의(id·AutoCAD 별칭·글리프·도구/즉시실행)를 단일 소스로 두고
  리본·명령행·단축키가 모두 이 레지스트리를 읽는다. tools/tool.consts.ts 폐지.
- ribbon/: 탭→패널→명령 데이터(ribbon.config.ts)와 범용 렌더러 분리.
  AutoCAD 배치(홈·삽입·주석·뷰·출력)와 패널 확장(▾)을 따른다.
- tools/factories/sequence-tool.ts: 점·숫자·문자·객체·선택 단계를 선언하면
  xstate 머신을 만들어 주는 공장. 명령당 170줄 보일러플레이트 제거.
- helpers/geometry/: 3점 호·정다각형·타원·스플라인·구름형·평행이동·해치 스캔선·
  점렬 샘플링 등 상태 없는 순수 함수.
- Toolbar 483줄을 QuickAccessBar/Ribbon/InspectorPanel/StatusBar/CommandLine/
  ViewControls/PropertiesEditor/QuickProperties로 분해.

■ 명령 (조사표 기준)
- 1절 그리기 21건, 2절 수정 27건, 3절 도면층·특성·그룹·유틸리티 39건 전부 반영.
- 5절 주석 34건 중 26건 반영(문자·치수 16종·지시선·표·구름형·주석 축척).
- HatchEntity 추가(solid·pattern·cross·gradient) + JSON 왕복, Layer에 색·선가중치·
  선종류·동결·투명도 필드 추가, 그리기 루프가 동결·숨김·투명도를 반영.

■ 화면 실측에서 고친 결함
- 명령행 포커스 상태에서 ENTER가 도구로 가지 않던 문제
- 명령행 문자 입력이 접두사가 같은 명령으로 실행되던 문제
- 명령이 끝나도 입력을 계속 먹던 문제(점 입력 명령만 반복, 나머지는 선택 도구 복귀)
- 시퀀스 단계 인덱스 오사용 9건 + 단계 값 종류 검사 추가
- 해치 내부를 클릭해도 선택되지 않던 문제

미반영 8건(맞춤법 검사·꺾기 치수·치수 끊기/재연관/검사 치수·기하공차·지시선 수집·
축척 리스트 편집)은 조사표 `반영` 열과 PLAN.md에 사유를 적었다.
This commit is contained in:
2026-08-30 01:34:13 +09:00
parent 60bbe89edb
commit ea9d9685be
88 changed files with 8052 additions and 850 deletions
@@ -0,0 +1,98 @@
/** 명령행 — AutoCAD 명령행처럼 기록·입력·자동완성을 담당한다. */
import { type FC, type FormEvent, useMemo, useRef, useState } from 'react';
import { describeCommand, matchCommandPrefixes } from '../commands/registry';
import { getCommandHistory, runCommandInput } from '../commands/run-command';
import { getInputController, getLastStateInstructions } from '../state';
export const CommandLine: FC = () => {
const [text, setText] = useState('');
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const history = getCommandHistory();
const instruction = getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.';
const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]);
const submit = (event: FormEvent) => {
event.preventDefault();
const value = text.trim();
if (!value) {
// 빈 ENTER는 활성 명령의 확정 신호다 (선택 끝내기·직전 명령 반복)
getInputController().handleEnterKey();
return;
}
// 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다
getInputController().submitText(value);
setText('');
setHistoryIndex(null);
};
const recallHistory = (direction: -1 | 1) => {
if (!history.length) return;
const nextIndex =
historyIndex === null
? history.length - 1
: Math.min(history.length - 1, Math.max(0, historyIndex + direction));
setHistoryIndex(nextIndex);
setText(history[nextIndex].split(' ')[0]);
};
return (
<section className="cad-command-area controls">
<div className="cad-command-history">
{history.slice(-3).map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: 기록은 순서 자체가 식별자다
<span key={`${line}-${index}`}>{line}</span>
))}
</div>
<form onSubmit={submit}>
<label htmlFor="cad-command">:</label>
<input
id="cad-command"
ref={inputRef}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
getInputController().handleEscapeKey();
}
if (event.key === 'ArrowUp') {
event.preventDefault();
recallHistory(-1);
}
if (event.key === 'ArrowDown') {
event.preventDefault();
recallHistory(1);
}
if (event.key === 'Tab' && suggestions.length) {
event.preventDefault();
setText(suggestions[0].id);
}
}}
placeholder="명령 입력 (예: L, LINE, REC, MOVE)"
autoComplete="off"
spellCheck={false}
/>
<span className="cad-command-prompt">{instruction}</span>
</form>
{text.trim() && suggestions.length > 0 && (
<div className="cad-command-suggestions">
{suggestions.map((command) => (
<button
type="button"
key={command.id}
onClick={() => {
runCommandInput(command.id);
setText('');
inputRef.current?.focus();
}}
>
{describeCommand(command)}
</button>
))}
</div>
)}
</section>
);
};
@@ -0,0 +1,68 @@
/** 좌측 팔레트 — 특성(PROPERTIES)과 도면층 관리자 (AutoCAD 팔레트 자리) */
import type { FC } from 'react';
import { LayerManager } from './LayerManager';
import { PropertiesEditor } from './PropertiesEditor';
import { getInspectorTab, openInspector } from './ui-state';
import {
getActiveLayerId,
getLayers,
setActiveLayerId,
setLayers,
} from '../state';
interface InspectorPanelProps {
collapsed: boolean;
onToggleCollapsed: () => void;
}
export const InspectorPanel: FC<InspectorPanelProps> = ({ collapsed, onToggleCollapsed }) => {
const tab = getInspectorTab();
const layers = getLayers();
const activeLayerId = getActiveLayerId();
return (
<aside className="cad-inspector controls" data-collapsed={collapsed}>
<button
className="cad-inspector__collapse"
type="button"
onClick={onToggleCollapsed}
title={collapsed ? '패널 펼치기' : '패널 접기'}
>
{collapsed ? '' : ''}
</button>
{!collapsed && (
<>
<div className="cad-inspector-tabs">
<button
type="button"
data-active={tab === 'properties'}
onClick={() => openInspector('properties')}
>
</button>
<button
type="button"
data-active={tab === 'layers'}
onClick={() => openInspector('layers')}
>
</button>
</div>
{tab === 'properties' ? (
<div className="cad-properties">
<PropertiesEditor />
</div>
) : (
<LayerManager
className="cad-layer-manager"
layers={layers}
activeLayerId={activeLayerId}
setLayers={(next) => setLayers(next)}
setActiveLayerId={(id) => setActiveLayerId(id)}
/>
)}
</>
)}
</aside>
);
};
@@ -93,7 +93,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Set this layer as active"
active={activeLayerId === layer.id}
onClick={(evt) => handleLayerClick(evt, layer.id)}
className="flex-grow"
className="flex-grow min-w-0 overflow-hidden"
left={
<>
<Button
@@ -101,7 +101,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Show/hide layer content"
onClick={(evt) => handleShowHideLayer(evt, layer.id)}
size="small"
className="w-10 -ml-1"
className="w-7 -ml-1"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -110,7 +110,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Lock/Unlock layer content"
onClick={(evt) => handleLockUnlockLayer(evt, layer.id)}
size="small"
className="w-10"
className="w-7"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -123,7 +123,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Select entities on this layer"
onClick={(evt) => handleSelectEntitiesOnLayer(evt, layer.id)}
size="small"
className="w-10"
className="w-7"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -132,7 +132,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="Assign current selection to layer"
onClick={(evt) => handleAssignSelectionToLayer(evt, layer.id)}
size="small"
className="w-10"
className="w-7"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -141,7 +141,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
title="delete layer and content"
onClick={(evt) => handleDeleteLayer(evt, layer.id)}
size="small"
className="w-10"
className="w-7"
type="transparent"
active={activeLayerId === layer.id}
/>
@@ -0,0 +1,154 @@
/** 특성 팔레트 본문 — 선택 객체의 값을 읽고 바로 고친다 (PROPERTIES) */
import type { FC } from 'react';
import type { Entity } from '../entities/Entity';
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
import {
getEntities,
getLayers,
getSelectedEntities,
setEntities,
} from '../state';
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
interface PropertiesEditorProps {
compact?: boolean;
}
function applyToSelection(mutate: (entity: Entity) => void): void {
const selected = getSelectedEntities();
if (!selected.length) return;
for (const entity of selected) mutate(entity);
setEntities([...getEntities()], true);
}
/** 여러 객체가 값이 다르면 '*가지각색' 대신 첫 객체 값을 보여 준다 (AutoCAD와 같은 관행) */
export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false }) => {
const selected = getSelectedEntities();
const layers = getLayers();
const first = selected[0];
if (!first) {
return <p className="cad-properties__empty"> .</p>;
}
const points = sampleEntityPoints(first);
const box = first.getBoundingBox();
return (
<div className="cad-properties-editor" data-compact={compact}>
<div className="cad-properties-editor__title">
{selected.length === 1 ? first.getType() : `여러 객체 (${selected.length})`}
</div>
<label>
<span></span>
<input
type="color"
value={first.lineColor}
onChange={(event) =>
applyToSelection((entity) => {
entity.lineColor = event.target.value;
})
}
/>
</label>
<label>
<span></span>
<select
value={first.lineWidth}
onChange={(event) =>
applyToSelection((entity) => {
entity.lineWidth = Number(event.target.value);
})
}
>
{LINE_WIDTHS.map((width) => (
<option key={width} value={width}>
{width}px
</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={dashToLineType(first.lineDash)}
onChange={(event) => {
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
applyToSelection((entity) => {
entity.lineDash = dash ? [...dash] : undefined;
});
}}
>
{LINE_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={first.layerId}
onChange={(event) =>
applyToSelection((entity) => {
entity.layerId = event.target.value;
})
}
>
{layers.map((layer) => (
<option key={layer.id} value={layer.id}>
{layer.name}
</option>
))}
</select>
</label>
<label>
<span></span>
<input
type="number"
min={0}
max={90}
step={5}
value={Math.round((1 - (first.opacity ?? 1)) * 100)}
onChange={(event) => {
const percent = Math.min(90, Math.max(0, Number(event.target.value)));
applyToSelection((entity) => {
entity.opacity = 1 - percent / 100;
});
}}
/>
</label>
{!compact && (
<dl className="cad-properties-editor__readout">
<div>
<dt></dt>
<dd>{polylineLength(points).toFixed(3)}</dd>
</div>
<div>
<dt></dt>
<dd>
{(box.xmax - box.xmin).toFixed(3)} × {(box.ymax - box.ymin).toFixed(3)}
</dd>
</div>
<div>
<dt></dt>
<dd>
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
</dd>
</div>
<div>
<dt></dt>
<dd>{first.groupId ? '있음' : '없음'}</dd>
</div>
</dl>
)}
</div>
);
};
@@ -0,0 +1,34 @@
/** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */
import type { FC } from 'react';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
export const QuickAccessBar: FC = () => (
<header className="cad-titlebar controls">
<div className="cad-brand">
<strong>Aislo CAD</strong>
<span>B07 </span>
</div>
<div className="cad-quick-access">
{QUICK_ACCESS_COMMANDS.map((id) => {
const command = getCommandById(id);
if (!command) return null;
return (
<button
type="button"
key={command.id}
title={`${command.label} (${command.id})`}
onClick={() => runCommand(command)}
>
<span aria-hidden>{command.glyph}</span>
</button>
);
})}
</div>
<div className="cad-file-state">
<span className="cad-file-state__dot" />
</div>
</header>
);
@@ -0,0 +1,21 @@
/** 빠른 특성 — 선택이 있을 때만 화면 오른쪽 위에 뜨는 간이 특성 상자 (QUICKPROPERTIES) */
import type { FC } from 'react';
import { PropertiesEditor } from './PropertiesEditor';
import { getSelectedEntities } from '../state';
import { isQuickPropertiesVisible, setQuickPropertiesVisible } from './ui-state';
export const QuickProperties: FC = () => {
if (!isQuickPropertiesVisible() || getSelectedEntities().length === 0) return null;
return (
<div className="cad-quick-properties controls">
<div className="cad-quick-properties__header">
<span> </span>
<button type="button" onClick={() => setQuickPropertiesVisible(false)} title="닫기">
</button>
</div>
<PropertiesEditor compact />
</div>
);
};
@@ -0,0 +1,191 @@
/** 리본 안에 들어가는 위젯 패널 — 특성(색·굵기·선종류), 도면층, 문자 스타일 */
import type { FC } from 'react';
import type { Entity } from '../entities/Entity';
import { EntityName } from '../entities/Entity';
import type { TextEntity } from '../entities/TextEntity';
import {
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
getActiveTextStyle,
getEntities,
getLayers,
getSelectedEntities,
setActiveLayerId,
setActiveLineColor,
setActiveLineDash,
setActiveLineWidth,
setActiveTextStyle,
setEntities,
} from '../state';
import { runCommandInput } from '../commands/run-command';
export 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] },
];
export const LINE_WIDTHS = [1, 2, 3, 4, 5];
const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace'];
export const dashToLineType = (dash: number[] | undefined): string =>
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
/** 선택 객체가 있으면 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
function applyToSelection(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;
}
export const PropertiesWidget: FC = () => {
const lineColor = getActiveLineColor();
const lineWidth = getActiveLineWidth();
const lineType = dashToLineType(getActiveLineDash());
return (
<div className="cad-ribbon-props">
<label className="cad-prop" title="객체 색상 (COLOR)">
<span></span>
<input
type="color"
value={lineColor}
onChange={(event) => {
setActiveLineColor(event.target.value);
applyToSelection((entity) => {
entity.lineColor = event.target.value;
});
}}
/>
</label>
<label className="cad-prop" title="선가중치 (LWEIGHT)">
<span></span>
<select
value={lineWidth}
onChange={(event) => {
const width = Number(event.target.value);
setActiveLineWidth(width);
applyToSelection((entity) => {
entity.lineWidth = width;
});
}}
>
{LINE_WIDTHS.map((width) => (
<option key={width} value={width}>
{width}px
</option>
))}
</select>
</label>
<label className="cad-prop" title="선종류 (LINETYPE)">
<span></span>
<select
value={lineType}
onChange={(event) => {
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
setActiveLineDash(dash ? [...dash] : undefined);
applyToSelection((entity) => {
entity.lineDash = dash ? [...dash] : undefined;
});
}}
>
{LINE_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</label>
</div>
);
};
export const LayersWidget: FC = () => {
const layers = getLayers();
const activeLayerId = getActiveLayerId();
return (
<div className="cad-ribbon-props">
<label className="cad-prop cad-prop--wide" title="현재 도면층">
<span> </span>
<select value={activeLayerId} onChange={(event) => setActiveLayerId(event.target.value)}>
{layers.map((layer) => (
<option key={layer.id} value={layer.id}>
{layer.name}
</option>
))}
</select>
</label>
<button
type="button"
className="cad-tool"
data-size="small"
title="도면층 특성 관리자 (LAYER)"
onClick={() => runCommandInput('LAYER')}
>
<span className="cad-tool__glyph"></span>
<span className="cad-tool__label"></span>
</button>
</div>
);
};
export const TextStyleWidget: FC = () => {
const textStyle = getActiveTextStyle();
const handle = (patch: Partial<typeof textStyle>) => {
setActiveTextStyle(patch);
applyToSelection((entity) => {
if (entity.getType() === EntityName.Text) {
(entity as TextEntity).setTextOptions(patch);
}
});
};
return (
<div className="cad-ribbon-props">
<label className="cad-prop cad-prop--wide" title="문자 글꼴 (STYLE)">
<span></span>
<select
value={textStyle.fontFamily}
onChange={(event) => handle({ 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) handle({ fontSize: size });
}}
/>
</label>
<label className="cad-prop" title="문자 색상">
<span></span>
<input
type="color"
value={textStyle.textColor}
onChange={(event) => handle({ textColor: event.target.value })}
/>
</label>
</div>
);
};
@@ -0,0 +1,67 @@
/** 상태막대 — AutoCAD 하단 제도 보조 토글 */
import type { FC } from 'react';
import {
getAngleStep,
getGridEnabled,
getSnapEnabled,
setAngleStep,
setGridEnabled,
setSnapEnabled,
} from '../state';
interface StatusBarProps {
commandLineVisible: boolean;
onToggleCommandLine: () => void;
}
export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleCommandLine }) => {
const snap = getSnapEnabled();
const grid = getGridEnabled();
const ortho = getAngleStep() === 90;
return (
<footer className="cad-statusbar controls">
<button
type="button"
data-active={snap}
title="객체 스냅 (OSNAP)"
onClick={() => setSnapEnabled(!snap)}
>
</button>
<button
type="button"
data-active={ortho}
title="직교 모드 — 켜면 90°, 끄면 45° 간격 각도 가이드"
onClick={() => setAngleStep(ortho ? 45 : 90)}
>
</button>
<button
type="button"
data-active={!ortho}
title="극좌표 추적 — 45° 간격 각도 가이드"
onClick={() => setAngleStep(ortho ? 45 : 90)}
>
</button>
<button
type="button"
data-active={grid}
title="그리드 표시"
onClick={() => setGridEnabled(!grid)}
>
</button>
<button
type="button"
data-active={commandLineVisible}
title="명령행 표시/숨기기"
onClick={onToggleCommandLine}
>
</button>
<span className="cad-statusbar__hint">: · 드래그: 초점이동 · Esc: 취소</span>
</footer>
);
};
@@ -1,483 +1,61 @@
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';
/**
* CAD 화면 골격 조립 — 제목표시줄 · 리본 · 좌측 팔레트 · 탐색막대 · 명령행 · 상태막대.
* 각 조각은 자기 파일에 있고, 여기서는 배치와 표시 상태만 다룬다.
*/
import { type FC, useEffect, useState } from 'react';
import { CommandLine } from './CommandLine';
import { InspectorPanel } from './InspectorPanel';
import { QuickProperties } from './QuickProperties';
import { LayersWidget, PropertiesWidget, TextStyleWidget } from './RibbonWidgets';
import { QuickAccessBar } from './QuickAccessBar';
import { Ribbon } from '../ribbon/Ribbon';
import { StatusBar } from './StatusBar';
import { useCadRefresh } from './use-cad-refresh';
import { getActiveToolActor } from '../state';
import type { Tool } from '../tools';
import { ViewControls } from './ViewControls';
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');
useCadRefresh();
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 [commandLineVisible, setCommandLineVisible] = useState(true);
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]);
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
useEffect(() => {
document.documentElement.style.setProperty(
'--cad-panel-width',
panelCollapsed ? '0px' : '248px'
);
document.documentElement.style.setProperty(
'--cad-command-height',
commandLineVisible ? '86px' : '0px'
);
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('선택 문자 스타일 변경');
}
};
}, [panelCollapsed, commandLineVisible]);
return (
<>
<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>
))}
<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>
<QuickAccessBar />
<Ribbon
activeTool={activeTool}
widgets={{
properties: <PropertiesWidget />,
layers: <LayersWidget />,
textStyle: <TextStyleWidget />,
}}
/>
<InspectorPanel
collapsed={panelCollapsed}
onToggleCollapsed={() => setPanelCollapsed((value) => !value)}
/>
<ViewControls />
<QuickProperties />
{commandLineVisible && <CommandLine />}
<StatusBar
commandLineVisible={commandLineVisible}
onToggleCommandLine={() => setCommandLineVisible((value) => !value)}
/>
</>
);
};
@@ -0,0 +1,28 @@
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) */
import type { FC } from 'react';
import { runCommandInput } from '../commands/run-command';
import { getScreenCanvasDrawController } from '../state';
export const ViewControls: FC = () => {
const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1;
return (
<div className="cad-view-controls controls">
<button
type="button"
className="cad-view-controls__fit"
onClick={() => runCommandInput('ZOOM')}
title="범위 줌 (Z) — 도면 전체를 화면에 맞춘다"
>
</button>
<button type="button" onClick={() => runCommandInput('ZOOMIN')} title="확대">
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => runCommandInput('ZOOMOUT')} title="축소">
</button>
</div>
);
};
@@ -0,0 +1,40 @@
/**
* 화면 표시 상태 — 명령(레지스트리)에서도 팔레트를 열 수 있어야 해서
* 리액트 컴포넌트 바깥에 둔다. 값이 바뀌면 UPDATE_STATE로 다시 그린다.
*/
import { HtmlEvent } from '../App.types';
export type InspectorTab = 'properties' | 'layers';
let inspectorTab: InspectorTab = 'layers';
let inspectorCollapsed = false;
let quickPropertiesVisible = false;
let activeRibbonTab = 'home';
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
export const getInspectorTab = () => inspectorTab;
export const isInspectorCollapsed = () => inspectorCollapsed;
export const isQuickPropertiesVisible = () => quickPropertiesVisible;
export const getActiveRibbonTab = () => activeRibbonTab;
export function setActiveRibbonTab(tabId: string): void {
activeRibbonTab = tabId;
notify();
}
export function openInspector(tab: InspectorTab): void {
inspectorTab = tab;
inspectorCollapsed = false;
notify();
}
export function setInspectorCollapsed(collapsed: boolean): void {
inspectorCollapsed = collapsed;
notify();
}
export function setQuickPropertiesVisible(visible: boolean): void {
quickPropertiesVisible = visible;
notify();
}
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { HtmlEvent } from '../App.types';
/**
* CAD 상태(state.ts)는 리액트 밖에 있다. UPDATE_STATE 이벤트가 올 때마다
* 카운터를 올려 컴포넌트가 getter를 다시 읽게 한다.
*/
export function useCadRefresh(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
const handler = () => setTick((value) => value + 1);
window.addEventListener(HtmlEvent.UPDATE_STATE, handler);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, handler);
}, []);
return tick;
}