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