refactor(cad): CAD 앱 겉 자체 변종을 부품 상자 부품으로 — 상태막대 켜기 칩 · 패널 탭 · 떠 있는 판 · 메뉴 · 아이콘 단추 · 확인창 (PLAN 553-6)

- 부품 상자 CSS · 단추 기본 꼴을 CAD iframe 에 실음(integration/ui-kit.ts) · 자리 · 폭은 cad-* 그대로
- 상태막대 = ui-toggle--chip 22 · 특성/레이어 = ui-tabs 36 · ↶↷ · 리본 접기 · 탐색 막대 = ui-icon-btn 20 · 32
- 빠른 특성 · 블록 라이브러리 · 자리표 = ui-float-panel · 리본 넘침 · 스냅 · 명령 자동완성 = ui-ctx-menu
- window.confirm · window.prompt → askConfirm · askText · 빈 안내 = ui-empty 작은 꼴
- 높이 단: 리본 칸 19→18 · 명령 칸 26→28 · 레이어 줄 40→42 · 문서 탭 25→24 · 도각 편집 단추 24
- 마우스 바탕 = --color-hover · 켜짐 = --color-accent-soft · 도면 그림 색(--draw-*) 안 건드림
- 안 쓰는 DropdownButton 지움(쓰는 곳 0 · grep)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:35:43 +09:00
co-authored by Claude Opus 5.5
parent dbff07a4f8
commit de250ab759
24 changed files with 364 additions and 496 deletions
+20 -64
View File
@@ -27,7 +27,8 @@
--cad-text-dim: var(--color-text-secondary);
--cad-text-muted: var(--color-text-muted);
--cad-accent: var(--color-accent);
--cad-hover: var(--color-mist-violet);
/* 마우스 바탕 = 부품 상자와 같은 값 · 켜짐 바탕은 --color-accent-soft */
--cad-hover: var(--color-hover);
--cad-canvas: var(--draw-canvas);
}
@@ -111,21 +112,12 @@ body > canvas[data-id="canvas"] {
flex: 1;
min-width: 0;
}
/* 접기 단추 = 아이콘 단추 부품(단 20) · 여기는 자리만 */
.cad-ribbon-tabs__collapse {
width: 24px;
height: 20px;
align-self: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-ribbon-tabs__collapse:hover {
background: var(--cad-hover);
}
.cad-ribbon-tabs button {
/* 리본 탭 = CAD 고유 꼴 그대로(토큰만) */
.cad-ribbon-tabs > button:not(.ui-icon-btn) {
padding: 0 14px;
border: 1px solid transparent;
border-bottom: 0;
@@ -134,10 +126,10 @@ body > canvas[data-id="canvas"] {
color: var(--cad-text-dim);
font-size: 12px;
}
.cad-ribbon-tabs button:hover {
.cad-ribbon-tabs > button:not(.ui-icon-btn):hover {
background: var(--cad-hover);
}
.cad-ribbon-tabs button[data-active="true"] {
.cad-ribbon-tabs > button[data-active="true"] {
background: var(--cad-chrome);
border-color: var(--cad-line);
color: var(--color-text);
@@ -185,53 +177,25 @@ body > canvas[data-id="canvas"] {
.cad-ribbon-group__label[data-expandable="true"]:hover {
color: var(--cad-accent);
}
/* 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 자리만 */
.cad-ribbon-overflow {
/* 리본 패널 줄이 가로 스크롤 상자라 그 안에서는 잘린다 — 화면 좌표로 띄운다 */
position: fixed;
z-index: 20;
display: flex;
flex-direction: column;
min-width: 170px;
max-height: 72vh;
padding: 4px;
overflow-y: auto;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: 4px;
box-shadow: var(--shadow-lg);
}
.cad-ribbon-overflow button {
.cad-ribbon-overflow > .ui-ctx-menu__item {
display: flex;
gap: 8px;
align-items: center;
padding: 5px 8px;
border: 0;
border-radius: 3px;
background: transparent;
font-size: 11px;
text-align: left;
}
.cad-ribbon-overflow button:hover {
background: var(--cad-hover);
}
/* ↶ ↷ = 아이콘 단추 부품(단 20) · 여기는 자리만 */
.cad-quick-access {
display: flex;
gap: 2px;
align-self: center;
}
.cad-ribbon-tabs .cad-quick-access button {
width: 24px;
height: 20px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
line-height: 1;
font-size: 13px;
}
.cad-quick-access button:hover {
border-color: var(--cad-line);
background: var(--cad-hover);
}
.cad-ribbon-props {
display: flex;
flex-direction: column;
@@ -252,15 +216,16 @@ body > canvas[data-id="canvas"] {
width: 52px;
text-align: left;
}
/* 리본 안 칸 = CAD 고유 꼴(토큰만) · 높이 = 크기 단 18 · 둥글기 4 */
.cad-prop select,
.cad-prop input[type="number"] {
height: 19px;
height: 18px;
min-width: 78px;
padding: 0 3px;
color: var(--cad-text);
background: var(--cad-chrome-sunken);
border: 1px solid var(--cad-line);
border-radius: 3px;
border-radius: 4px;
font-size: 11px;
}
.cad-prop input[type="number"] {
@@ -268,13 +233,13 @@ body > canvas[data-id="canvas"] {
width: 52px;
}
.cad-prop input[type="color"] {
height: 19px;
height: 18px;
width: 44px;
padding: 1px;
background: var(--cad-chrome-sunken);
/* 흰색을 골라도 스와치 자리가 보이게 테두리를 진하게 준다 (2026-09-01) */
border: 1px solid var(--cad-text-muted);
border-radius: 3px;
border-radius: 4px;
cursor: pointer;
}
/* 고른 것들 색이 다름(488) — 빈 견본(투명 · 점선 테두리) */
@@ -328,7 +293,7 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans
}
.cad-tool[data-active="true"] {
border-color: var(--cad-accent);
background: var(--cad-hover);
background: var(--color-accent-soft);
box-shadow: inset 0 -2px var(--cad-accent);
}
.cad-tool:disabled {
@@ -373,23 +338,14 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans
background: var(--cad-chrome-raised);
color: var(--cad-text-dim);
}
/* 꼴 = 탭 부품(`ui-tabs`) · 여기는 칸 둘이 폭을 나누는 배치만 */
.cad-inspector-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
height: 38px;
border-bottom: 1px solid var(--cad-line);
gap: 0;
}
.cad-inspector-tabs button {
border: 0;
border-bottom: 2px solid transparent;
background: var(--cad-chrome-sunken);
color: var(--cad-text-dim);
font-size: 12px;
}
.cad-inspector-tabs button[data-active="true"] {
border-bottom-color: var(--cad-accent);
background: var(--cad-chrome-raised);
color: var(--color-text);
.cad-inspector-tabs > .ui-tabs__tab {
justify-content: center;
}
.cad-properties {
padding: 14px 12px;
@@ -74,7 +74,6 @@ export interface Layer {
export enum LOCAL_STORAGE_KEY {
DRAWING = 'OPEN_WEB_CAD__DRAWING',
DROPDOWN = 'OPEN_WEB_CAD__DROPDOWN',
}
export interface StartAndEndpointEntity {
+33 -182
View File
@@ -14,22 +14,22 @@
padding-right: 2px;
min-width: 0;
}
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
/* 도면층 버튼 — 목록 줄 부품(`ui-sidebar-row`) 꼴 · 상태는 Button이 붙이는 data-* 속성으로 가른다. */
.cad-layer-manager button[data-type="regular"] {
background: var(--cad-chrome-sunken);
color: var(--cad-accent);
background: transparent;
color: var(--color-text-body);
}
.cad-layer-manager button[data-type="transparent"] {
background: transparent;
color: var(--cad-accent);
color: var(--color-text-secondary);
}
.cad-layer-manager button:hover {
background: var(--cad-hover);
background: var(--color-hover);
color: var(--color-text);
}
.cad-layer-manager button[data-active="true"] {
background: var(--color-primary);
color: var(--color-primary-text);
background: var(--color-accent-soft);
color: var(--color-accent);
}
.cad-view-controls {
@@ -41,7 +41,7 @@
align-items: center;
overflow: hidden;
border: 1px solid var(--cad-line);
border-radius: 4px;
border-radius: var(--radius-buttons);
background: var(--cad-chrome-raised);
box-shadow: var(--shadow-lg);
}
@@ -87,21 +87,7 @@ body.cad-file-dragging::after {
pointer-events: none;
}
.cad-view-controls button {
width: 34px;
height: 32px;
border: 0;
border-right: 1px solid var(--cad-line);
background: transparent;
font-size: 17px;
}
.cad-view-controls button:hover {
background: var(--cad-hover);
}
.cad-view-controls__fit {
color: var(--cad-accent);
font-size: 15px;
}
/* 단추 = 아이콘 단추 부품(단 32) · 여기는 배율 글만 */
.cad-view-controls span {
min-width: 48px;
color: var(--cad-text-dim);
@@ -133,30 +119,16 @@ body.cad-file-dragging::after {
color: var(--cad-text-muted);
font: 11px Consolas, monospace;
}
/* 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 자리와 명령 글꼴만 */
.cad-command-suggestions {
position: absolute;
z-index: 6;
bottom: 100%;
left: 10px;
display: flex;
flex-direction: column;
min-width: 220px;
padding: 4px;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: 4px;
box-shadow: var(--shadow-lg);
}
.cad-command-suggestions button {
padding: 4px 8px;
border: 0;
border-radius: 3px;
background: transparent;
font: 11px Consolas, monospace;
text-align: left;
}
.cad-command-suggestions button:hover {
background: var(--cad-hover);
.cad-command-suggestions > .ui-ctx-menu__item {
font: 12px Consolas, monospace;
}
.cad-prop--wide select {
min-width: 132px;
@@ -180,12 +152,13 @@ body.cad-file-dragging::after {
font-size: 11px;
font-weight: 600;
}
/* 명령 칸 = CAD 고유 꼴(토큰만) · 높이 = 크기 단 28 */
.cad-command-area input {
flex: 1;
height: 26px;
height: 28px;
padding: 0 8px;
border: 1px solid var(--cad-line);
border-radius: 2px;
border-radius: var(--radius-buttons);
outline: none;
background: var(--cad-chrome-sunken);
color: var(--cad-text);
@@ -210,23 +183,7 @@ body.cad-file-dragging::after {
background: var(--cad-chrome);
border-top: 1px solid var(--cad-line);
}
.cad-statusbar button {
height: 21px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 2px;
background: transparent;
color: var(--cad-text-dim);
font-size: 10px;
}
.cad-statusbar button:hover {
background: var(--cad-hover);
}
.cad-statusbar button[data-active="true"] {
border-color: var(--cad-accent);
background: var(--color-primary);
color: var(--color-primary-text);
}
/* 켜기 = 켜기 칩 부품(`ui-toggle--chip` · 단 22) */
.cad-statusbar__hint {
margin-left: auto;
color: var(--cad-text-muted);
@@ -261,13 +218,14 @@ body.cad-file-dragging::after {
font-size: 11px;
color: var(--cad-text-dim);
}
/* 칸 = 크기 단 24(둥글기 6) · 토큰만 */
.cad-properties-editor select,
.cad-properties-editor input {
height: 24px;
min-width: 0;
padding: 0 4px;
border: 1px solid var(--cad-line);
border-radius: 3px;
border-radius: 6px;
background: var(--cad-chrome-sunken);
color: var(--cad-text);
font-size: 11px;
@@ -292,59 +250,19 @@ body.cad-file-dragging::after {
color: var(--cad-text);
text-overflow: ellipsis;
}
.cad-properties__empty {
margin: 0;
color: var(--cad-text-muted);
font-size: 11px;
}
/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */
.cad-quick-properties {
position: fixed;
z-index: 5;
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
right: 14px;
width: 240px;
padding: 8px 10px 10px;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: 5px;
box-shadow: var(--shadow-lg);
}
.cad-quick-properties__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-quick-properties__header button {
border: 0;
background: transparent;
color: var(--cad-text-muted);
font-size: 12px;
}
/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */
/* 빠른 특성 · 블록 라이브러리 — 꼴 = 떠 있는 판 부품(`ui-float-panel`) · 여기는 자리 · 폭만 */
.cad-quick-properties,
.cad-block-library {
position: fixed;
z-index: 5;
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
right: 14px;
width: 240px;
padding: 8px 10px 10px;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: 5px;
box-shadow: var(--shadow-lg);
}
.cad-block-library__empty {
margin: 0;
color: var(--cad-text-muted);
font-size: 11px;
line-height: 1.5;
}
/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */
.cad-block-library__list {
max-height: 220px;
margin: 0;
@@ -357,40 +275,26 @@ body.cad-file-dragging::after {
align-items: center;
gap: 4px;
}
.cad-block-library__list li button {
border: 0;
background: transparent;
color: var(--cad-text);
font-size: 12px;
}
/* 블록 줄 = 목록 줄 부품(`ui-sidebar-row`) · 지우기 = 아이콘 단추 부품(단 20) · 여기는 줄 폭만 */
.cad-block-library__name {
flex: 1;
min-width: 0;
overflow: hidden;
padding: 3px 4px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.cad-block-library__name:hover {
background: var(--cad-chrome-sunken);
}
/* 단추 = 단추 부품(보조 · 단 24) · 여기는 둘이 폭을 나눔만 */
.cad-block-library__actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.cad-block-library__actions button {
.cad-block-library__actions > .ui-btn {
flex: 1;
padding: 4px 0;
border: 1px solid var(--cad-line);
border-radius: 3px;
background: transparent;
color: var(--cad-text-dim);
font-size: 11px;
}
/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로
부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */
부모 사이드바에서 캐드 안으로 옮김). 꼴 = 떠 있는 판 부품(`ui-float-panel`) · 여기는 자리 · 폭 · 줄 배치만. */
.cad-frame-tokens {
position: fixed;
/* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */
@@ -398,36 +302,13 @@ body.cad-file-dragging::after {
bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px);
z-index: 3;
max-height: 46vh;
overflow-y: auto;
width: 236px;
}
.cad-frame-tokens__body {
display: flex;
flex-direction: column;
gap: 6px;
width: 236px;
padding: 8px;
border: 1px solid var(--cad-line);
border-radius: 6px;
background: var(--cad-chrome-raised);
box-shadow: var(--shadow-lg);
color: var(--cad-text);
}
.cad-frame-tokens__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
color: var(--cad-text-dim);
font-size: 11px;
line-height: 1.4;
}
.cad-frame-tokens__fold {
flex: none;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: transparent;
color: var(--cad-text);
cursor: pointer;
}
.cad-frame-tokens__group {
@@ -439,39 +320,9 @@ body.cad-file-dragging::after {
.cad-frame-tokens__title {
width: 100%;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-frame-tokens__button {
padding: 3px 7px;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: var(--cad-chrome);
color: var(--cad-text);
font-size: 11px;
cursor: pointer;
}
.cad-frame-tokens__button:hover {
background: var(--cad-accent-soft, var(--cad-chrome-raised));
}
/* 자리표 칸 크기 입력 (2026-09-06) */
.cad-frame-tokens__size {
display: flex;
align-items: center;
gap: 4px;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-frame-tokens__size input {
/* 자리표 칸 크기 입력 (2026-09-06) — 칸 = 입력 부품(단 24) */
.cad-frame-tokens__size > .ui-input {
width: 64px;
padding: 2px 4px;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: var(--cad-chrome);
color: var(--cad-text);
font-size: 11px;
}
@@ -5,6 +5,7 @@ import { setBlockLibraryVisible } from '../components/ui-state';
import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file';
import { pickFile } from '../helpers/pick-file';
import { importDrawingFile } from '../integration/dxf-clip-paste';
import { askText } from '../integration/ui-kit';
import { getActiveToolActor, getSelectedEntities } from '../state';
import { Tool } from '../tools';
import { imageImportToolStateMachine } from '../tools/image-import-tool';
@@ -39,13 +40,15 @@ export const INSERT_COMMANDS: CadCommand[] = [
needsSelection: true,
run: () => {
const entities = getSelectedEntities();
const name = window.prompt('블록 이름', '')?.trim();
if (!name) return '블록 작성 취소';
void saveBlockFromEntities(name, entities).then(() => {
// 글 묻기 = 확인창 부품(window.prompt 대신 · 553-6)
void askText({ message: '블록 이름', okLabel: '저장' }).then(async (text) => {
const name = text?.trim();
if (!name) return;
await saveBlockFromEntities(name, entities);
toast.success(`블록 '${name}' 저장 (객체 ${entities.length}개)`);
setBlockLibraryVisible(true);
});
return `블록 '${name}' 저장`;
return '블록 이름 입력';
},
},
{
@@ -1,4 +1,4 @@
/** 블록 라이브러리 팔레트 — 저장한 블록을 고르면 삽입 도구가 켜진다 (INSERT/BLOCK) */
/** 블록 라이브러리 팔레트 — 저장한 블록을 고르면 삽입 도구가 켜진다 (INSERT/BLOCK) · 떠 있는 판 부품 */
import type { FC } from 'react';
import {
deleteBlock,
@@ -7,6 +7,7 @@ import {
importBlocksFromFile,
} from '../blocks/block-library';
import { pickFile } from '../helpers/pick-file';
import { askConfirm } from '../integration/ui-kit';
import { startBlockInsert } from '../tools/insert-block-tool';
import { isBlockLibraryVisible, setBlockLibraryVisible } from './ui-state';
@@ -16,57 +17,83 @@ export const BlockLibraryPanel: FC = () => {
const blocks = getBlocks();
return (
<div className="cad-block-library controls">
<div className="cad-quick-properties__header">
<span>블록 라이브러리</span>
<button type="button" onClick={() => setBlockLibraryVisible(false)} title="닫기">
<div className="ui-float-panel cad-block-library controls">
<div className="ui-float-panel__head">
<span className="ui-float-panel__title">블록 라이브러리</span>
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="24"
onClick={() => setBlockLibraryVisible(false)}
title="닫기"
aria-label="닫기"
>
✕
</button>
</div>
<div className="ui-float-panel__body">
{blocks.length === 0 ? (
<div className="ui-empty ui-empty--small">
<p className="ui-empty__text">객체를 선택하고 BLOCK 명령으로 저장하십시오.</p>
</div>
) : (
<ul className="cad-block-library__list">
{blocks.map((block) => (
<li key={block.name}>
<button
type="button"
className="ui-sidebar-row cad-block-library__name"
title={`객체 ${block.entities.length}개 — 클릭하면 삽입`}
onClick={() => {
setBlockLibraryVisible(false);
void startBlockInsert(block);
}}
>
{block.name}
</button>
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="20"
title="삭제"
aria-label="삭제"
onClick={async () => {
if (
await askConfirm({
message: `블록 '${block.name}'을 삭제하시겠습니까?`,
okLabel: '삭제',
danger: true,
})
) {
deleteBlock(block.name);
}
}}
>
✕
</button>
</li>
))}
</ul>
)}
{blocks.length === 0 ? (
<p className="cad-block-library__empty">객체를 선택하고 BLOCK 명령으로 저장하십시오.</p>
) : (
<ul className="cad-block-library__list">
{blocks.map((block) => (
<li key={block.name}>
<button
type="button"
className="cad-block-library__name"
title={`객체 ${block.entities.length}개 — 클릭하면 삽입`}
onClick={() => {
setBlockLibraryVisible(false);
void startBlockInsert(block);
}}
>
{block.name}
</button>
<button
type="button"
title="삭제"
onClick={() => {
if (window.confirm(`블록 '${block.name}'을 삭제하시겠습니까?`)) {
deleteBlock(block.name);
}
}}
>
✕
</button>
</li>
))}
</ul>
)}
<div className="cad-block-library__actions">
<button type="button" onClick={() => exportBlocksToFile()}>
파일로 내보내기
</button>
<button
type="button"
onClick={() => pickFile('application/json', (file) => importBlocksFromFile(file))}
>
파일에서 가져오기
</button>
<div className="cad-block-library__actions">
<button
type="button"
className="ui-btn ui-btn--ghost"
data-size="24"
onClick={() => exportBlocksToFile()}
>
파일로 내보내기
</button>
<button
type="button"
className="ui-btn ui-btn--ghost"
data-size="24"
onClick={() => pickFile('application/json', (file) => importBlocksFromFile(file))}
>
파일에서 가져오기
</button>
</div>
</div>
</div>
);
@@ -37,9 +37,9 @@ export const Button: FC<ButtonProps> = ({
left = null,
right = null,
}) => {
// 색은 App.css의 테마 토큰이 data-type/data-active로 준다 — 여기서는 배치만.
// 색은 App.css의 테마 토큰이 data-type/data-active로 준다 — 여기서는 배치만 · 높이 = 크기 단 42(글 14 · 둥글기 8)
const classParts = [
'font-semibold py-4 h-10 flex flex-row justify-start w-full items-center',
'font-semibold h-[42px] text-sm rounded-lg flex flex-row justify-start w-full items-center',
size === 'regular' ? 'pl-2 pr-2 gap-2' : '',
size === 'small' ? 'pl-2 pr-2 gap-0' : '',
className || '',
@@ -16,7 +16,10 @@ export const CommandLine: FC = () => {
const history = getCommandHistory();
const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.';
const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]);
const suggestions = useMemo(
() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8),
[text, pick]
);
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -102,10 +105,11 @@ export const CommandLine: FC = () => {
<span className="cad-command-prompt">{instruction}</span>
</form>
{text.trim() && suggestions.length > 0 && (
<div className="cad-command-suggestions">
<div className="ui-ctx-menu cad-command-suggestions">
{suggestions.map((command) => (
<button
type="button"
className="ui-ctx-menu__item"
key={command.id}
onClick={() => {
runCommandInput(command.id);
@@ -1,69 +0,0 @@
import type { CSSProperties, FC, ReactNode } from 'react';
import useLocalStorageState from 'use-local-storage-state';
import { LOCAL_STORAGE_KEY } from '../App.types.ts';
import { keyboardHandler } from '../helpers/keyboard-handler.ts';
import { Button } from './Button.tsx';
import { Icon, IconName } from './Icon/Icon.tsx';
interface DropdownButtonProps {
label?: string;
title?: string;
iconName?: IconName;
iconComponent?: ReactNode;
active?: boolean;
onClick?: () => void;
className?: string;
style?: CSSProperties;
buttonStyle?: CSSProperties;
dataId: string;
children?: ReactNode;
defaultOpen?: boolean;
}
export const DropdownButton: FC<DropdownButtonProps> = ({
label,
title,
iconName,
iconComponent,
className,
style,
buttonStyle,
dataId,
children,
defaultOpen = false,
}) => {
const [isOpen, setIsOpen] = useLocalStorageState<boolean>(
`${LOCAL_STORAGE_KEY.DROPDOWN}___${dataId}`,
{ defaultValue: defaultOpen }
);
const classParts = [
'flex flex-col gap-2 relative',
className || '',
isOpen ? ' bg-slate-900' : '',
];
return (
<div className={classParts.join(' ')} style={style} data-id={dataId}>
<Button
iconName={iconName}
iconComponent={iconComponent}
label={label}
title={title}
active={isOpen}
onClick={() => setIsOpen(!isOpen)}
style={buttonStyle}
className={'w-full data-[active=true]:bg-blue-950 data-[active=true]:text-white'}
/>
<Icon name={IconName.SolidDownSmall} className={'absolute top-2.5 right-1 text-blue-700'} />
{isOpen && (
<div
className="flex flex-row flex-wrap max-w-72 gap-1 pl-1 pb-6"
onClick={() => setIsOpen(false)}
onKeyUp={keyboardHandler(() => setIsOpen(false))}
>
{children}
</div>
)}
</div>
);
};
@@ -139,15 +139,15 @@ export const FramePlaceholderPanel: FC = () => {
if (!visible) return null;
return (
<section className="cad-frame-tokens controls">
<header className="cad-frame-tokens__header">
<span>
{folded ? '자리표 놓기' : '자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다'}
</span>
<section className="ui-float-panel cad-frame-tokens controls">
<header className="ui-float-panel__head cad-frame-tokens__header">
<span className="ui-float-panel__title">자리표 놓기</span>
<button
type="button"
className="cad-frame-tokens__fold"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="24"
aria-expanded={!folded}
aria-label={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
title={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
onClick={() => setFolded(!folded)}
>
@@ -155,58 +155,71 @@ export const FramePlaceholderPanel: FC = () => {
</button>
</header>
{!folded && (
<>
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">글자</span>
{TEXT_TOKENS.map((token) => (
<button
key={token}
type="button"
className="cad-frame-tokens__button"
onClick={() => addTextPlaceholder(token)}
>
{token}
</button>
))}
</div>
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">그림 (칸에 비율 그대로 들어감)</span>
{IMAGE_TOKENS.map((token) => (
<button
key={token}
type="button"
className="cad-frame-tokens__button"
onClick={() => void addImagePlaceholder(token)}
>
{token}
</button>
))}
</div>
{picked && (
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">고른 자리표 칸 크기 (mm)</span>
<label className="cad-frame-tokens__size">
가로
<input
type="number"
min={1}
value={size.width}
onChange={(event) => applySize(Number(event.target.value), size.height)}
/>
</label>
<label className="cad-frame-tokens__size">
세로
<input
type="number"
min={1}
value={size.height}
onChange={(event) => applySize(size.width, Number(event.target.value))}
/>
</label>
<div className="ui-float-panel__body cad-frame-tokens__body">
<p className="ui-text ui-text--muted">
누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다
</p>
<div className="cad-frame-tokens__group">
<span className="ui-text ui-text--muted cad-frame-tokens__title">글자</span>
{TEXT_TOKENS.map((token) => (
<button
key={token}
type="button"
className="ui-btn ui-btn--ghost"
data-size="22"
onClick={() => addTextPlaceholder(token)}
>
{token}
</button>
))}
</div>
<div className="cad-frame-tokens__group">
<span className="ui-text ui-text--muted cad-frame-tokens__title">
그림 (칸에 비율 그대로 들어감)
</span>
{IMAGE_TOKENS.map((token) => (
<button
key={token}
type="button"
className="ui-btn ui-btn--ghost"
data-size="22"
onClick={() => void addImagePlaceholder(token)}
>
{token}
</button>
))}
</div>
{picked && (
<div className="cad-frame-tokens__group">
<span className="ui-text ui-text--muted cad-frame-tokens__title">
고른 자리표 칸 크기 (mm)
</span>
<label className="ui-text ui-text--muted cad-frame-tokens__size">
가로
<input
type="number"
className="ui-input"
data-size="24"
min={1}
value={size.width}
onChange={(event) => applySize(Number(event.target.value), size.height)}
/>
</label>
<label className="ui-text ui-text--muted cad-frame-tokens__size">
세로
<input
type="number"
className="ui-input"
data-size="24"
min={1}
value={size.height}
onChange={(event) => applySize(size.width, Number(event.target.value))}
/>
</label>
</div>
)}
</div>
)}
</>
)}
</section>
);
};
@@ -27,17 +27,24 @@ export const InspectorPanel: FC<InspectorPanelProps> = ({ collapsed, onToggleCol
</button>
{!collapsed && (
<>
<div className="cad-inspector-tabs">
{/* 탭 부품(밑줄 탭 · 단 36) — 칸 둘이 폭을 나눠 가짐 */}
<div className="ui-tabs cad-inspector-tabs" role="tablist">
<button
type="button"
data-active={tab === 'properties'}
role="tab"
className={`ui-tabs__tab${tab === 'properties' ? ' is-active' : ''}`}
data-size="36"
aria-selected={tab === 'properties'}
onClick={() => openInspector('properties')}
>
특성
</button>
<button
type="button"
data-active={tab === 'layers'}
role="tab"
className={`ui-tabs__tab${tab === 'layers' ? ' is-active' : ''}`}
data-size="36"
aria-selected={tab === 'layers'}
onClick={() => openInspector('layers')}
>
레이어
@@ -18,7 +18,8 @@ interface PropertiesEditorProps {
}
/** 고른 것을 사본으로 바꿈 — 되돌리기 한 칸(488) · 선 특성은 글 뺌 */
const applyToSelection = (mutate: (entity: Entity) => void) => restyle(getSelectedEntities(), mutate);
const applyToSelection = (mutate: (entity: Entity) => void) =>
restyle(getSelectedEntities(), mutate);
const applyToLines = (mutate: (entity: Entity) => void) =>
restyle(styleTargets(getSelectedEntities()), mutate);
@@ -30,7 +31,11 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
const view = selectionLineStyle();
if (!first) {
return <p className="cad-properties__empty">선택된 객체가 없습니다.</p>;
return (
<div className="ui-empty ui-empty--small">
<p className="ui-empty__text">선택된 객체가 없습니다.</p>
</div>
);
}
const points = sampleEntityPoints(first);
@@ -1,4 +1,4 @@
/** 빠른 실행 단추(↶ ↷) — 리본 탭 줄 오른쪽(접기 단추 앞)에 그린다 · 숨김 목록은 DOM 만 둔다 */
/** 빠른 실행 단추(↶ ↷) — 리본 탭 줄 오른쪽(접기 단추 앞)에 그린다 · 아이콘 단추 부품(단 20) · 숨김 목록은 DOM 만 둔다 */
import type { FC } from 'react';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
@@ -16,6 +16,9 @@ export const QuickAccessBar: FC = () => {
return (
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="20"
aria-label={command.label}
key={command.id}
hidden={QUICK_ACCESS_HIDDEN.includes(id)}
title={`${command.label} (${command.id})`}
@@ -1,4 +1,4 @@
/** 빠른 특성 — 선택이 있을 때만 화면 오른쪽 위에 뜨는 간이 특성 상자 (QUICKPROPERTIES) */
/** 빠른 특성 — 선택이 있을 때만 화면 오른쪽 위에 뜨는 간이 특성 상자 (QUICKPROPERTIES) · 떠 있는 판 부품 */
import type { FC } from 'react';
import { PropertiesEditor } from './PropertiesEditor';
import { getSelectedEntities } from '../state';
@@ -8,14 +8,23 @@ 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="닫기">
<div className="ui-float-panel cad-quick-properties controls">
<div className="ui-float-panel__head">
<span className="ui-float-panel__title">빠른 특성</span>
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="24"
onClick={() => setQuickPropertiesVisible(false)}
title="닫기"
aria-label="닫기"
>
✕
</button>
</div>
<PropertiesEditor compact />
<div className="ui-float-panel__body">
<PropertiesEditor compact />
</div>
</div>
);
};
@@ -1,4 +1,4 @@
/** 상태막대 — AutoCAD 하단 제도 보조 토글 */
/** 상태막대 — AutoCAD 하단 제도 보조 토글 · 켜기 칩 부품(`ui-toggle--chip` · 단 22) */
import type { FC } from 'react';
import {
getAngleStep,
@@ -27,7 +27,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
<footer className="cad-statusbar controls">
<button
type="button"
data-active={snap}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={snap}
title="객체 스냅 (OSNAP)"
onClick={() => setSnapEnabled(!snap)}
>
@@ -35,7 +37,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={ortho}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={ortho}
title="직교 모드 (F8) — 90° 간격 각도 가이드"
onClick={() => setAngleStep(ortho ? 0 : 90)}
>
@@ -43,7 +47,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={polar}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={polar}
title="극좌표 추적 (F10) — 45° 간격 각도 가이드"
onClick={() => setAngleStep(polar ? 0 : 45)}
>
@@ -51,7 +57,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={snapTracking}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={snapTracking}
title="객체 스냅 추적 — 스냅점에 잠시 머물면 그 점에서 정렬선이 뻗는다"
onClick={() => setSnapTrackingEnabled(!snapTracking)}
>
@@ -59,7 +67,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={grid}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={grid}
title="그리드 표시"
onClick={() => setGridEnabled(!grid)}
>
@@ -67,7 +77,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={commandLineVisible}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={commandLineVisible}
title="명령행 표시/숨기기"
onClick={onToggleCommandLine}
>
@@ -3,12 +3,10 @@ import type { FC } from 'react';
import { runCommandInput } from '../commands/run-command';
import { redoCommand, undoCommand } from '../inputController/prompt-input';
import { zoomExtents } from '../inputController/zoom-prompt';
import {
getScreenCanvasDrawController,
isDrawingReadOnly,
isHostSave,
isViewOnly,
} from '../state';
import { getScreenCanvasDrawController, isDrawingReadOnly, isHostSave, isViewOnly } from '../state';
/** 아이콘 단추 부품(단 32) */
const ICON_BUTTON = { className: 'ui-icon-btn ui-icon-btn--ghost', 'data-size': '32' } as const;
export const ViewControls: FC = () => {
const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1;
@@ -19,27 +17,52 @@ export const ViewControls: FC = () => {
<div className="cad-view-controls controls">
{showUndo && (
<>
<button type="button" onClick={undoCommand} title="실행 취소 (Ctrl+Z)">
<button
type="button"
{...ICON_BUTTON}
onClick={undoCommand}
title="실행 취소 (Ctrl+Z)"
aria-label="실행 취소"
>
↶
</button>
<button type="button" onClick={redoCommand} title="다시 실행 (Ctrl+Y)">
<button
type="button"
{...ICON_BUTTON}
onClick={redoCommand}
title="다시 실행 (Ctrl+Y)"
aria-label="다시 실행"
>
↷
</button>
</>
)}
<button
type="button"
className="cad-view-controls__fit"
{...ICON_BUTTON}
onClick={() => zoomExtents()}
title="범위 줌 (Z) — 도면 전체를 화면에 맞춘다"
aria-label="범위 줌"
>
⛶
</button>
<button type="button" onClick={() => runCommandInput('ZOOMIN')} title="확대">
<button
type="button"
{...ICON_BUTTON}
onClick={() => runCommandInput('ZOOMIN')}
title="확대"
aria-label="확대"
>
+
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => runCommandInput('ZOOMOUT')} title="축소">
<button
type="button"
{...ICON_BUTTON}
onClick={() => runCommandInput('ZOOMOUT')}
title="축소"
aria-label="축소"
>
-
</button>
</div>
@@ -1,29 +1,5 @@
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 커서 자리 · 위 겹침만 */
.cad-snap-menu {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 160px;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-sm);
}
.cad-snap-menu > button {
padding: 4px 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.cad-snap-menu > button:hover,
.cad-snap-menu > button:focus-visible {
background: var(--color-surface-hover, rgba(127, 127, 127, 0.15));
}
@@ -55,11 +55,12 @@ export function openSnapMenu(x: number, y: number, onPick: (name: string) => voi
current = { onPick, element: null };
if (typeof document === 'undefined') return;
const menu = document.createElement('div');
menu.className = 'cad-snap-menu controls';
menu.className = 'ui-ctx-menu cad-snap-menu controls'; // 꼴 = 메뉴 부품
menu.setAttribute('role', 'menu');
for (const [name, label] of SNAP_MENU) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'ui-ctx-menu__item';
item.setAttribute('role', 'menuitem');
item.textContent = `${label} (${name})`;
item.addEventListener('click', () => pickSnapMenu(name));
@@ -0,0 +1,18 @@
/**
* 부품 상자(`ui_template/`) CAD 쪽 한 벌(553-6) — CAD 겉도 앱 화면과 같은 부품 꼴을 씀.
* 부품 CSS 를 싣고 단추 · 확인창 기본 꼴(`injectBaseStyles`)을 한 번 넣음 · 확인창 · 글 묻기를 다시 냄.
* React 화면은 부품 클래스(`ui-btn` · `ui-icon-btn` · `ui-toggle--chip` · `ui-tabs` · `ui-float-panel` · `ui-ctx-menu` · `ui-empty` · `ui-text` · `ui-input`)와
* `data-size` 단을 직접 붙임 · 자리 · 폭은 App.css 의 `cad-*` 가 맡음 · 도면 그림 색(`--draw-*`)은 안 건드림.
*/
import '../../../../ui_template/ui_template_tabs.css';
import '../../../../ui_template/ui_template_toggle.css';
import '../../../../ui_template/ui_template_float_panel.css';
import '../../../../ui_template/ui_template_context_menu.css';
import '../../../../ui_template/ui_template_empty.css';
import '../../../../ui_template/ui_template_input.css';
import '../../../../ui_template/ui_template_text.css';
import { injectBaseStyles } from '../../../../ui_template/ui_template_elements_styles.ts';
export { askConfirm, askText } from '../../../../ui_template/ui_template_modal.ts';
injectBaseStyles();
+2
View File
@@ -3,6 +3,8 @@ import { Point } from '@flatten-js/core';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Actor, type MachineSnapshot } from 'xstate';
// 부품 상자 CSS 를 앱 CSS 보다 먼저 실음 — 자리 · 폭은 뒤에 오는 App.css 의 `cad-*` 가 덮음
import './integration/ui-kit.ts';
import { SNAP_POINT_DISTANCE } from './App.consts';
import App from './App.tsx';
import { TOOL_STATE_MACHINES } from './commands/registry';
@@ -129,10 +129,14 @@ const Panel: FC<{
{overflowCommands.length ? ' ▾' : ''}
</button>
{overflowAt && overflowCommands.length > 0 && (
<div className="cad-ribbon-overflow" style={{ top: overflowAt.top, left: overflowAt.left }}>
<div
className="ui-ctx-menu cad-ribbon-overflow"
style={{ top: overflowAt.top, left: overflowAt.left }}
>
{overflowCommands.map((command) => (
<button
type="button"
className="ui-ctx-menu__item"
key={command.id}
title={command.hint}
onClick={() => {
@@ -228,7 +232,9 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
<QuickAccessBar />
<button
type="button"
className="cad-ribbon-tabs__collapse"
className="ui-icon-btn ui-icon-btn--ghost cad-ribbon-tabs__collapse"
data-size="20"
aria-label={collapsed ? '리본 펼치기' : '리본 접기'}
onClick={() => {
setPeek(false);
setRibbonCollapsed(!collapsed);