feat: React19+HMDesign shell with ported DWG/DXF viewer engine
Scaffolds the new app (Vite+React19+Tailwind v4) on HMDesign via git submodule + npm workspace, ports src/viewer2d/* and optionsStore.ts unchanged, and rebuilds the vanilla UI (toolbar/layers/properties/ options panels, coord readout, license dialog, load progress) as HM Design System components. Panel drag/collapse ported from panelUtils.ts as a React hook; Measure UI intentionally left unwired per original app parity. Typecheck and dev-server module serving verified; in-browser rendering not yet verified in this session (no browser tool available) — needs manual check. Refs #1
This commit is contained in:
1 parent
1fd0b00df3
commit
b114581c07
54 files changed
+16494
No files matched your search
@@ -0,0 +1,24 @@
|
||||
function formatCadCoord(v: number | null): string {
|
||||
if (v === null || !Number.isFinite(v)) return '—';
|
||||
const abs = Math.abs(v);
|
||||
const digits = abs >= 1e6 ? 1 : abs >= 1e3 ? 2 : abs >= 1 ? 3 : 4;
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
export interface CoordReadoutProps {
|
||||
point: { x: number; y: number; z: number } | null;
|
||||
spaceKind: 'model' | 'paper' | 'other' | null;
|
||||
}
|
||||
|
||||
export function CoordReadout({ point, spaceKind }: CoordReadoutProps) {
|
||||
const unitLabel = spaceKind === 'paper' ? 'Paper' : 'UCS';
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-3 right-3 z-10 rounded-md border bg-card/90 px-3 py-1.5 font-mono text-xs text-muted-foreground shadow-sm backdrop-blur"
|
||||
title={`마우스 위치 CAD 좌표 (${spaceKind === 'paper' ? 'Layout=Paper' : 'Model=UCS'} 도면단위)`}
|
||||
>
|
||||
X {formatCadCoord(point?.x ?? null)} · Y {formatCadCoord(point?.y ?? null)}{' '}
|
||||
<span className="opacity-60">({unitLabel})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardAction } from '@hm-design/ui';
|
||||
import { Button } from '@hm-design/ui';
|
||||
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
export interface DraggablePanelProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
initial: { left: number; top: number };
|
||||
width?: number;
|
||||
height?: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
headerActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DraggablePanel({
|
||||
title,
|
||||
icon,
|
||||
initial,
|
||||
width = 320,
|
||||
height = 420,
|
||||
open,
|
||||
onClose,
|
||||
headerActions,
|
||||
children,
|
||||
}: DraggablePanelProps) {
|
||||
const { panelRef, style, collapsed, toggleCollapse, headerProps } = useDraggablePanel(initial);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={panelRef}
|
||||
size="sm"
|
||||
className="fixed shadow-lg overflow-hidden"
|
||||
style={{
|
||||
...style,
|
||||
width,
|
||||
height: collapsed ? 'auto' : height,
|
||||
minWidth: 250,
|
||||
minHeight: collapsed ? 'auto' : 140,
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
resize: collapsed ? 'none' : 'both',
|
||||
}}
|
||||
>
|
||||
<CardHeader className="select-none border-b py-2" {...headerProps}>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
{headerActions}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={collapsed ? '펼치기' : '접기'}
|
||||
title={collapsed ? '펼치기' : '접기'}
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
{collapsed ? <ChevronRight /> : <ChevronDown />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
{!collapsed && (
|
||||
<CardContent className="overflow-auto" style={{ height: 'calc(100% - 44px)' }}>
|
||||
{children}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch } from '@hm-design/ui';
|
||||
import { Eye, EyeOff, Layers } from 'lucide-react';
|
||||
import { DraggablePanel } from './DraggablePanel';
|
||||
import type { LayerRow } from '../hooks/useDwgViewer';
|
||||
|
||||
export interface LayerPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
layers: LayerRow[];
|
||||
hiddenLayers: Set<string>;
|
||||
onToggleLayer: (name: string) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
}
|
||||
|
||||
export function LayerPanel({ open, onClose, layers, hiddenLayers, onToggleLayer, onShowAll, onHideAll }: LayerPanelProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return layers;
|
||||
return layers.filter((l) => l.name.toLowerCase().includes(q));
|
||||
}, [layers, query]);
|
||||
|
||||
return (
|
||||
<DraggablePanel
|
||||
title={`레이어 (${layers.length}개)`}
|
||||
icon={<Layers className="size-4" />}
|
||||
initial={{ left: window.innerWidth - 344, top: 56 }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FilterBar>
|
||||
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
|
||||
<FilterBarActions className="ml-2 gap-1">
|
||||
<Button type="button" variant="outline" size="xs" onClick={onShowAll}>
|
||||
전체 켜기
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" onClick={onHideAll}>
|
||||
전체 끄기
|
||||
</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
|
||||
{layers.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">도면에 레이어 정보가 없습니다</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">검색 결과가 없습니다 ("{query}")</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{filtered.map((layer) => {
|
||||
const visible = !hiddenLayers.has(layer.name);
|
||||
return (
|
||||
<li
|
||||
key={layer.name}
|
||||
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-muted/50"
|
||||
style={{ opacity: visible ? 1 : 0.45 }}
|
||||
>
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full border"
|
||||
style={{ background: layer.colorHex }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={layer.name}>
|
||||
{layer.name}
|
||||
</span>
|
||||
<Badge variant="secondary">{layer.count}</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={visible ? '숨기기' : '표시하기'}
|
||||
title={visible ? '숨기기' : '표시하기'}
|
||||
onClick={() => onToggleLayer(layer.name)}
|
||||
>
|
||||
{visible ? <Eye /> : <EyeOff />}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</DraggablePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@hm-design/ui';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
const PIPELINE_STEPS = [
|
||||
'파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)',
|
||||
'CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)',
|
||||
'Three.js 씬 구성 및 렌더링',
|
||||
];
|
||||
|
||||
export function LicenseNotice() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label="정보 및 라이선스" title="정보 및 라이선스">
|
||||
<Info />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>모듈 구성 · 라이선스 고지</DialogTitle>
|
||||
<DialogDescription>이 뷰어가 사용하는 처리 파이프라인과 서드파티 라이선스 고지입니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm text-foreground">
|
||||
{PIPELINE_STEPS.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="flex flex-col gap-1 border-t pt-3 text-sm">
|
||||
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/THIRD-PARTY-NOTICES.md" target="_blank" rel="noreferrer">
|
||||
THIRD-PARTY-NOTICES.md
|
||||
</a>
|
||||
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-MPL-2.0.txt" target="_blank" rel="noreferrer">
|
||||
acadrust MPL-2.0 전문
|
||||
</a>
|
||||
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-0.4.1-read_mesh.patch" target="_blank" rel="noreferrer">
|
||||
acadrust 0.4.1 read_mesh 패치
|
||||
</a>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Progress } from '@hm-design/ui';
|
||||
|
||||
export interface LoadProgressProps {
|
||||
active: boolean;
|
||||
value: number | null;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function LoadProgress({ active, value, error }: LoadProgressProps) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<Progress
|
||||
value={value ?? undefined}
|
||||
className={`fixed inset-x-0 top-0 z-50 h-1 rounded-none ${
|
||||
value === null ? 'animate-pulse' : ''
|
||||
} ${error ? '[&>*]:bg-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Button, Input, Label, Slider, Switch } from '@hm-design/ui';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { DraggablePanel } from './DraggablePanel';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import type { PersistedOptions } from '../hooks/useDwgViewer';
|
||||
|
||||
function SliderNumberRow({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
unit,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
size="small"
|
||||
className="w-20"
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) onChange(v);
|
||||
}}
|
||||
/>
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[value]}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
aria-label={label}
|
||||
onValueChange={([v]) => onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
options: PersistedOptions;
|
||||
is25D: boolean;
|
||||
lineweightClasses: number[];
|
||||
onSetGrid: (on: boolean) => void;
|
||||
onSetZoomSpeed: (v: number) => void;
|
||||
onSetZScale: (v: number) => void;
|
||||
onSetLineweightOn: (on: boolean | null) => void;
|
||||
onSetLineweightScale: (v: number | null) => void;
|
||||
onSet25D: (on: boolean) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function OptionsPanel({
|
||||
open,
|
||||
onClose,
|
||||
options,
|
||||
is25D,
|
||||
lineweightClasses,
|
||||
onSetGrid,
|
||||
onSetZoomSpeed,
|
||||
onSetZScale,
|
||||
onSetLineweightOn,
|
||||
onSetLineweightScale,
|
||||
onSet25D,
|
||||
onReset,
|
||||
}: OptionsPanelProps) {
|
||||
const lwScale = options.lineweightScale ?? 11;
|
||||
const lwPreview =
|
||||
lineweightClasses.length > 0
|
||||
? lineweightClasses.map((mm) => `${mm.toFixed(2)}mm → ${(mm * lwScale).toFixed(1)}px`).join(' · ')
|
||||
: `이 도면에는 굵은 선이 없습니다 (0.50mm 였다면 ${(0.5 * lwScale).toFixed(1)}px)`;
|
||||
|
||||
return (
|
||||
<DraggablePanel
|
||||
title="옵션"
|
||||
icon={<SlidersHorizontal className="size-4" />}
|
||||
initial={{ left: window.innerWidth - 676, top: 56 }}
|
||||
width={340}
|
||||
height={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
headerActions={
|
||||
<Button type="button" variant="ghost" size="icon-xs" title="기본값으로" aria-label="기본값으로" onClick={onReset}>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">선가중치</h4>
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
표시 사용
|
||||
<Switch
|
||||
checked={options.lineweightOn ?? false}
|
||||
onCheckedChange={(on) => onSetLineweightOn(on)}
|
||||
/>
|
||||
</label>
|
||||
<SliderNumberRow
|
||||
label="표시 배율 (px/mm)"
|
||||
value={lwScale}
|
||||
min={2}
|
||||
max={60}
|
||||
step={0.5}
|
||||
onChange={onSetLineweightScale}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{lwPreview}</p>
|
||||
<p className="text-xs text-muted-foreground">AutoCAD의 "선가중치 설정 → 표시 배율"과 동일합니다.</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t pt-4">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표시</h4>
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
그리드
|
||||
<Switch checked={options.grid} onCheckedChange={onSetGrid} />
|
||||
</label>
|
||||
<SliderNumberRow
|
||||
label="줌 속도"
|
||||
value={options.zoomSpeed}
|
||||
min={0.2}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={onSetZoomSpeed}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t pt-4">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">2.5D</h4>
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
2.5D 보기
|
||||
<Switch checked={is25D} onCheckedChange={onSet25D} />
|
||||
</label>
|
||||
<div className={is25D ? '' : 'pointer-events-none opacity-50'}>
|
||||
<SliderNumberRow
|
||||
label="Z 높이 배율"
|
||||
value={options.zScale}
|
||||
min={1}
|
||||
max={50}
|
||||
step={0.5}
|
||||
onChange={onSetZScale}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</DraggablePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Badge, Button } from '@hm-design/ui';
|
||||
import { Info, MousePointerClick, ZoomIn } from 'lucide-react';
|
||||
import { DraggablePanel } from './DraggablePanel';
|
||||
import { formatEntityProperties } from '../lib/propertyFormat';
|
||||
|
||||
function PropertyRows({ rows }: { rows: [string, string][] }) {
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between gap-3 py-1.5 text-xs">
|
||||
<span className="shrink-0 text-muted-foreground">{k}</span>
|
||||
<span className="truncate text-right font-mono text-foreground" style={{ userSelect: 'all' }} title={v}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PropertiesPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
entity: unknown;
|
||||
onZoomToSelection: () => void;
|
||||
}
|
||||
|
||||
export function PropertiesPanel({ open, onClose, entity, onZoomToSelection }: PropertiesPanelProps) {
|
||||
const prop = formatEntityProperties(entity);
|
||||
|
||||
return (
|
||||
<DraggablePanel
|
||||
title="속성"
|
||||
icon={<Info className="size-4" />}
|
||||
initial={{ left: window.innerWidth - 12 - 320, top: 56 }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
headerActions={
|
||||
entity ? (
|
||||
<Button type="button" variant="ghost" size="icon-xs" title="선택 확대" aria-label="선택 확대" onClick={onZoomToSelection}>
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!entity ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center text-xs text-muted-foreground">
|
||||
<MousePointerClick className="size-6 opacity-50" />
|
||||
도면에서 객체(엔티티)를 클릭하면
|
||||
<br />
|
||||
상세 속성이 표시됩니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{prop.id}
|
||||
</Badge>
|
||||
<div>
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
일반 정보 (General)
|
||||
</h4>
|
||||
<PropertyRows rows={prop.general} />
|
||||
</div>
|
||||
{prop.geometry.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-primary">
|
||||
기하 속성 (Geometry)
|
||||
</h4>
|
||||
<PropertyRows rows={prop.geometry} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DraggablePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useRef } from 'react';
|
||||
import { Button, Switch, Tabs, TabsList, TabsTrigger, Badge, Toggle } from '@hm-design/ui';
|
||||
import { FolderOpen, Scan, Layers, SlidersHorizontal, Info as InfoIcon } from 'lucide-react';
|
||||
import type { CadSpace } from '../viewer2d/cadSpaces';
|
||||
import { LicenseNotice } from './LicenseNotice';
|
||||
|
||||
export interface ToolbarProps {
|
||||
status: string;
|
||||
statusError: boolean;
|
||||
spaces: CadSpace[];
|
||||
activeSpace: string | null;
|
||||
onSelectSpace: (handleHex: string | null) => void;
|
||||
onOpenFile: (file: File) => void;
|
||||
onLoadSample: (url: string, label: string) => void;
|
||||
onFit: () => void;
|
||||
is25D: boolean;
|
||||
onToggle25D: (on: boolean) => void;
|
||||
canvasDark: boolean;
|
||||
onToggleTheme: () => void;
|
||||
layersOpen: boolean;
|
||||
onToggleLayers: () => void;
|
||||
optionsOpen: boolean;
|
||||
onToggleOptions: () => void;
|
||||
propsOpen: boolean;
|
||||
onToggleProps: () => void;
|
||||
}
|
||||
|
||||
export function Toolbar({
|
||||
status,
|
||||
statusError,
|
||||
spaces,
|
||||
activeSpace,
|
||||
onSelectSpace,
|
||||
onOpenFile,
|
||||
onLoadSample,
|
||||
onFit,
|
||||
is25D,
|
||||
onToggle25D,
|
||||
canvasDark,
|
||||
onToggleTheme,
|
||||
layersOpen,
|
||||
onToggleLayers,
|
||||
optionsOpen,
|
||||
onToggleOptions,
|
||||
propsOpen,
|
||||
onToggleProps,
|
||||
}: ToolbarProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const showSpaceTabs = spaces.length > 1 || (spaces[0]?.entityCount ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex items-center gap-2 border-b bg-card/90 px-3 py-2 backdrop-blur [&>*]:pointer-events-auto">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".dwg,.dxf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onOpenFile(file);
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderOpen /> 파일 열기
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" title="acadrust-dwg WASM" onClick={() => onLoadSample('/samples/BasicSample.dwg', 'BasicSample.dwg')}>
|
||||
Sample DWG
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => onLoadSample('/samples/civil.dwg', 'C0060202-001-평면및종단면도.dwg')}>
|
||||
Sample Civil
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" title="dxf-parser" onClick={() => onLoadSample('/samples/simple.dxf', 'simple.dxf')}>
|
||||
Sample DXF
|
||||
</Button>
|
||||
|
||||
{showSpaceTabs && (
|
||||
<Tabs value={activeSpace ?? undefined} onValueChange={(v) => onSelectSpace(v)}>
|
||||
<TabsList>
|
||||
{spaces.map((s) => (
|
||||
<TabsTrigger key={s.handleHex} value={s.handleHex} title={`${s.name} · ${s.handleHex}`}>
|
||||
{s.label}
|
||||
{s.entityCount > 0 ? ` (${s.entityCount})` : ''}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onFit} title="Fit (F)">
|
||||
<Scan /> Fit
|
||||
</Button>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Switch checked={is25D} onCheckedChange={onToggle25D} />
|
||||
2.5D
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Switch checked={canvasDark} onCheckedChange={onToggleTheme} />
|
||||
캔버스 테마
|
||||
</label>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Toggle pressed={layersOpen} onPressedChange={onToggleLayers} size="sm" aria-label="레이어 패널" title="Layers">
|
||||
<Layers />
|
||||
</Toggle>
|
||||
<Toggle pressed={optionsOpen} onPressedChange={onToggleOptions} size="sm" aria-label="옵션 패널" title="Options">
|
||||
<SlidersHorizontal />
|
||||
</Toggle>
|
||||
<Toggle pressed={propsOpen} onPressedChange={onToggleProps} size="sm" aria-label="속성 패널" title="Properties">
|
||||
<InfoIcon />
|
||||
</Toggle>
|
||||
<LicenseNotice />
|
||||
</div>
|
||||
|
||||
<Badge variant={statusError ? 'destructive' : 'secondary'} className="max-w-[320px] truncate">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user