feat(ui): rebuild the viewer UI with HMDesign components

New React UI around the unchanged engine (#1). GNB_ONLY page:

- AppHeader: GNB + GNBBrand; sample drawings in GNBNav (NavigationMenu)
  and GNBMobileMenu (same data, real ?model= links); file open and
  about/notice in GNBUtilities.
- DwgViewerCanvas: useRef/useEffect wrapper. new Viewer2D(host) on
  mount, option props diffed and pushed through the engine setters,
  onSelect/onPointerWorld/onViewChange forwarded to React. Viewer2D has
  no dispose(), so teardown stops the rAF loop / WebGL context from
  outside (also needed for StrictMode double mount).
- ViewerToolbar: Model/Layout ToggleGroup (sample's visibility rule),
  Fit, 2.5D and canvas-theme Toggles, window Toggles.
- FloatingPanel: HM Card in a fixed frame; drag/collapse via the ported
  makePanelDraggableAndCollapsible, CSS resize.
- LayersPanel (FilterBar search, Switch per layer, show/hide all via
  LayerPanelManager), PropertiesPanel (formatEntityProperties → Badge +
  Table, zoom to selection), OptionsPanel (Switch/Slider/Input, IndexedDB
  through optionsStore, reset).
- Load feedback per HMDesign: Progress for the measurable part
  (download bytes, stage steps) and LoadingScreen for the blocking
  WASM parse / Viewer2D.load(), painted before each blocking stage.
- StatusBar with status line, zoom %, live CAD coordinates in the
  bottom-right corner; AboutDialog keeps the acadrust MPL-2.0 notice,
  THIRD-PARTY-NOTICES and the local patch reachable.
- Drag & drop, ?model= auto-load, F shortcut, ResizeObserver resync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
minsungandClaude Opus 5.5 committed 2026-10-12 18:49:33 +09:00
1 parent 40befda905
commit aaa0972bc1
18 files changed
+2163

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"
import { flushSync } from "react-dom"
import { Input, Toaster, TooltipProvider, toast } from "@hm-design/ui"
import type { Viewer2D } from "./engine/viewer2d/Viewer2D.js"
import { listCadSpaces, pickDefaultSpace, type CadSpace } from "./engine/viewer2d/cadSpaces"
import { is2D } from "./engine/viewer2d/ext2d"
import { LayerPanelManager, type LayerItem } from "./engine/layerPanel"
import { getAllOptions, setOption } from "./engine/optionsStore"
import { nameFromUrl, parseCad, parserLabel } from "./lib/cad"
import { fetchWithProgress, formatBytes, nextPaint } from "./lib/loadProgress"
import type { SampleDrawing } from "./lib/samples"
import { createValueStore } from "./lib/store"
import { DwgViewerCanvas, type CadPoint, type ViewerDisplayOptions } from "./components/DwgViewerCanvas"
import { AppHeader } from "./components/AppHeader"
import { ViewerToolbar } from "./components/ViewerToolbar"
import { StatusBar, type StatusMessage } from "./components/StatusBar"
import { LayersPanel } from "./components/LayersPanel"
import { PropertiesPanel } from "./components/PropertiesPanel"
import { OptionsPanel, type EngineOptionState } from "./components/OptionsPanel"
import { AboutDialog } from "./components/AboutDialog"
import { DropOverlay, EmptyState, LoadingOverlay, type LoadState } from "./components/StageOverlays"
/** Persisted options (IndexedDB hmw-viewer/options). `undefined` = never set → engine default. */
type Prefs = {
lineweightScale?: number | null
lineweightOn?: boolean | null
grid: boolean
zoomSpeed?: number
zScale?: number
}
type Source = { kind: "url"; url: string; name?: string } | { kind: "file"; file: File }
const positive = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v > 0
export function App() {
const [viewer, setViewer] = useState<Viewer2D | null>(null)
const viewerRef = useRef<Viewer2D | null>(null)
const layerManager = useRef<LayerPanelManager | null>(null)
const stageRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const pointer = useMemo(() => createValueStore<CadPoint | null>(null), [])
const zoom = useMemo(() => createValueStore<number | null>(null), [])
const [status, setStatus] = useState<StatusMessage>({ text: "준비 중…" })
const [loadState, setLoadState] = useState<LoadState | null>(null)
const [hasDrawing, setHasDrawing] = useState(false)
const [spaces, setSpaces] = useState<CadSpace[]>([])
const [activeSpace, setActiveSpace] = useState<string | null>(null)
const [layers, setLayers] = useState<LayerItem[]>([])
const [selected, setSelected] = useState<unknown>(null)
const [panels, setPanels] = useState({ layers: false, props: false, options: false })
const [aboutOpen, setAboutOpen] = useState(false)
const aboutOpener = useRef<HTMLElement | null>(null)
const [dragging, setDragging] = useState(false)
const [dark, setDark] = useState(true)
const [view25D, setView25D] = useState(false)
const [prefs, setPrefs] = useState<Prefs>({ grid: false })
const [engine, setEngine] = useState<EngineOptionState>({
lineweightEnabled: false,
lineweightScale: 20,
lineweightClasses: [],
zoomSpeed: 1,
zScale: 1,
})
const displayOptions = useMemo<ViewerDisplayOptions>(
() => ({
dark,
grid: prefs.grid,
zoomSpeed: prefs.zoomSpeed,
lineweightEnabled: prefs.lineweightOn,
lineweightScale: prefs.lineweightScale,
zScale: prefs.zScale,
view25D,
}),
[dark, prefs, view25D],
)
// ── engine → React (reads only) ─────────────────────────────────────────────
/** main.ts syncOptions(): put the controls back to what the engine actually uses. */
const readEngine = useCallback(() => {
const v = viewerRef.current
if (!v) return
setEngine({
lineweightEnabled: v.getLineweightEnabled(),
lineweightScale: v.getLineweightScale(),
lineweightClasses: v.getLineweightClasses(),
zoomSpeed: v.getZoomSpeed(),
zScale: v.getZScale(),
})
zoom.set(v.getZoomPercent())
}, [zoom])
const fillLayers = useCallback(() => {
const v = viewerRef.current
if (!v) return
layerManager.current?.update()
setLayers(v.getLayerInfo())
}, [])
const fillSpaces = useCallback(() => {
const v = viewerRef.current
if (!v) return
setSpaces(v.getSpaces())
setActiveSpace(v.getActiveSpace())
}, [])
const onReady = useCallback((v: Viewer2D | null) => {
viewerRef.current = v
// The ported manager owns the hidden-layer set and calls Viewer2D.setHiddenLayers(); its own DOM rendering
// goes to a detached element because the list is rendered by React (LayersPanel) from getLayerInfo().
layerManager.current = v ? new LayerPanelManager(v, document.createElement("div")) : null
setViewer(v)
}, [])
const onSelect = useCallback((entity: unknown) => {
if (!entity) {
setStatus({ text: "선택 해제" })
setSelected(null)
return
}
// Show the properties window automatically on selection.
setPanels((p) => ({ ...p, props: true }))
setSelected(entity)
const e = entity as { type?: string; layer?: string; handle?: { value?: string } }
setStatus({ text: `선택 ${e.type ?? "?"} · 레이어 ${e.layer ?? "-"} · 핸들 ${e.handle?.value ?? "-"}` })
}, [])
const onViewChange = useCallback(() => {
zoom.set(viewerRef.current?.getZoomPercent() ?? null)
}, [zoom])
// Theme changes re-render the drawing with theme-dependent layer colors (the sample re-filled the list too).
// Child effects run first, so the canvas has already called setTheme() here.
useEffect(() => {
if (viewer) fillLayers()
}, [viewer, dark, fillLayers])
// ── load pipeline (main.ts ④/⑤ with staged progress) ─────────────────────────
const loadSeq = useRef(0)
const run = useCallback(
async (label: string, source: Source) => {
const v = viewerRef.current
if (!v) return
const seq = ++loadSeq.current
const stale = () => seq !== loadSeq.current
setStatus({ text: `${label} 여는 중…` })
setLoadState({ label: "도면 여는 중", value: 0, busy: true })
try {
let buf: ArrayBuffer
let name: string
if (source.kind === "url") {
name = source.name ?? nameFromUrl(source.url)
buf = await fetchWithProgress(source.url, (loaded, total) => {
if (stale()) return
const size = total ? `${formatBytes(loaded)} / ${formatBytes(total)}` : formatBytes(loaded)
setLoadState({ label: `도면 내려받는 중 (${size})`, value: total ? Math.round(Math.min(1, loaded / total) * 50) : null, busy: true })
})
} else {
name = source.file.name
if (!is2D(name)) throw new Error(`.dwg / .dxf 파일이 아닙니다: ${name}`)
setLoadState({ label: "파일 읽는 중", value: 10, busy: true })
buf = await source.file.arrayBuffer()
}
if (stale()) return
// Parsing blocks the main thread (WASM / dxf-parser): commit and paint the busy state first.
flushSync(() => setLoadState({ label: `도면 해석 중 · ${parserLabel(buf, name)}`, value: 55, busy: true }))
await nextPaint()
const result = await parseCad(buf, name)
if (stale()) return
// Viewer2D.load() tessellates synchronously as well.
flushSync(() => setLoadState({ label: "화면 구성 중", value: 85, busy: true }))
await nextPaint()
const allSpaces = listCadSpaces(result)
const def = pickDefaultSpace(allSpaces)
v.load(result, { spaceHandle: def?.handleHex ?? null })
v.resize()
readEngine()
const active = allSpaces.find((s) => s.handleHex === v.getActiveSpace())
setStatus({ text: `✓ ${label} · 엔티티 ${result.entities?.length ?? 0}개${active ? ` · ${active.label}` : ""}` })
setHasDrawing(true)
setSelected(null)
fillSpaces()
fillLayers()
v.fit()
zoom.set(v.getZoomPercent())
setLoadState({ label: "완료", value: 100, busy: false })
window.setTimeout(() => {
if (!stale()) setLoadState(null)
}, 400)
} catch (err) {
if (stale()) return
const message = (err as Error).message
setStatus({ text: `${label}: ${message}`, error: true })
setLoadState(null)
toast.error(`${label} 열기 실패`, { description: message })
console.error(err)
}
},
[readEngine, fillSpaces, fillLayers, zoom],
)
const runRef = useRef(run)
useEffect(() => {
runRef.current = run
}, [run])
const openAbout = useCallback(() => {
aboutOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setAboutOpen(true)
}, [])
const loadSample = useCallback((s: SampleDrawing) => void run(s.name, { kind: "url", url: s.path, name: s.name }), [run])
const openFile = useCallback(() => fileRef.current?.click(), [])
const onFileChange = (ev: ChangeEvent<HTMLInputElement>) => {
const file = ev.target.files?.[0]
if (!file) return
void run(file.name, { kind: "file", file })
ev.target.value = ""
}
// `?model=` auto-load, once the engine exists.
const booted = useRef(false)
useEffect(() => {
if (!viewer || booted.current) return
booted.current = true
const model = new URLSearchParams(location.search).get("model")
if (model) void runRef.current(model, { kind: "url", url: model })
else setStatus({ text: "준비됨 · 도면을 열거나 화면에 끌어다 놓으세요" })
}, [viewer])
// Restore saved options. IndexedDB is async; values are applied as soon as they arrive.
useEffect(() => {
let alive = true
void getAllOptions().then((o) => {
if (!alive) return
setPrefs((p) => ({
...p,
...(positive(o.lineweightScale) ? { lineweightScale: o.lineweightScale } : {}),
...(typeof o.lineweightOn === "boolean" ? { lineweightOn: o.lineweightOn } : {}),
...(o.grid === true ? { grid: true } : {}),
...(positive(o.zoomSpeed) ? { zoomSpeed: o.zoomSpeed } : {}),
...(positive(o.zScale) ? { zScale: o.zScale } : {}),
}))
})
return () => {
alive = false
}
}, [])
// F = fit (the sample's host shortcut; skipped while typing in an input).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.key === "f" || e.key === "F") && (e.target as HTMLElement)?.tagName !== "INPUT") viewerRef.current?.fit()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
// Drag & drop anywhere on the page.
useEffect(() => {
let depth = 0
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files")
const onOver = (e: DragEvent) => e.preventDefault()
const onEnter = (e: DragEvent) => {
if (!hasFiles(e)) return
depth++
setDragging(true)
}
const onLeave = (e: DragEvent) => {
if (!hasFiles(e)) return
depth = Math.max(0, depth - 1)
if (!depth) setDragging(false)
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
depth = 0
setDragging(false)
const file = e.dataTransfer?.files?.[0]
if (file) void runRef.current(file.name, { kind: "file", file })
}
window.addEventListener("dragover", onOver)
window.addEventListener("dragenter", onEnter)
window.addEventListener("dragleave", onLeave)
window.addEventListener("drop", onDrop)
return () => {
window.removeEventListener("dragover", onOver)
window.removeEventListener("dragenter", onEnter)
window.removeEventListener("dragleave", onLeave)
window.removeEventListener("drop", onDrop)
}
}, [])
// ── commands (React → engine) ───────────────────────────────────────────────
const onSpace = (s: CadSpace) => {
const v = viewerRef.current
if (!v || s.handleHex === v.getActiveSpace()) return
v.setSpace(s.handleHex)
fillSpaces()
fillLayers()
v.fit()
zoom.set(v.getZoomPercent())
setStatus({ text: `공간 ${s.label} · 엔티티 ${s.entityCount}개` })
}
const apply25D = (on: boolean) => {
setView25D(on)
setStatus({ text: on ? "2.5D 보기 · 왼쪽 드래그=회전, 가운데·오른쪽 드래그=이동" : "Top 뷰" })
}
const onToggleLayer = (name: string) => {
layerManager.current?.toggleLayer(name)
setLayers(viewerRef.current?.getLayerInfo() ?? [])
}
const onShowAll = () => {
layerManager.current?.showAllLayers()
setLayers(viewerRef.current?.getLayerInfo() ?? [])
}
const onHideAll = () => {
layerManager.current?.hideAllLayers()
setLayers(viewerRef.current?.getLayerInfo() ?? [])
}
// Options: state → canvas effect → engine; each change is saved to IndexedDB like the sample.
const onLineweightScale = (v: number) => {
setPrefs((p) => ({ ...p, lineweightScale: v }))
void setOption("lineweightScale", v)
}
const onLineweightEnabled = (on: boolean) => {
setPrefs((p) => ({ ...p, lineweightOn: on }))
void setOption("lineweightOn", on)
}
const onGrid = (on: boolean) => {
setPrefs((p) => ({ ...p, grid: on }))
void setOption("grid", on)
}
const onZoomSpeed = (v: number) => {
setPrefs((p) => ({ ...p, zoomSpeed: v }))
void setOption("zoomSpeed", v)
}
const onZScale = (v: number) => {
setPrefs((p) => ({ ...p, zScale: v }))
void setOption("zScale", v)
}
const onReset = () => {
// Engine default lineweight scale, the drawing's own LWDISPLAY, grid off, zoom ×1, Z ×1, top view.
setPrefs({ lineweightScale: null, lineweightOn: null, grid: false, zoomSpeed: 1, zScale: 1 })
apply25D(false)
void setOption("lineweightScale", null)
void setOption("lineweightOn", null)
void setOption("grid", false)
void setOption("zoomSpeed", 1)
void setOption("zScale", 1)
}
return (
<TooltipProvider>
<div className="viewer-app">
<AppHeader onOpenFile={openFile} onSample={loadSample} onAbout={openAbout} />
<main className="viewer-main" aria-label="도면 뷰어">
<ViewerToolbar
spaces={spaces}
activeSpace={activeSpace}
onSpace={onSpace}
hasDrawing={hasDrawing}
onFit={() => viewerRef.current?.fit()}
view25D={view25D}
on25D={apply25D}
dark={dark}
onDark={setDark}
layersOpen={panels.layers}
onLayers={(open) => setPanels((p) => ({ ...p, layers: open }))}
propsOpen={panels.props}
onProps={(open) => setPanels((p) => ({ ...p, props: open }))}
optionsOpen={panels.options}
onOptions={(open) => setPanels((p) => ({ ...p, options: open }))}
/>
<div ref={stageRef} className="viewer-stage">
<DwgViewerCanvas
className="viewer-canvas"
options={displayOptions}
onReady={onReady}
onSelect={onSelect}
onPointerWorld={pointer.set}
onViewChange={onViewChange}
onOptionsApplied={readEngine}
/>
{!hasDrawing && !loadState ? <EmptyState onOpenFile={openFile} onSample={loadSample} /> : null}
{loadState ? <LoadingOverlay state={loadState} /> : null}
{dragging ? <DropOverlay /> : null}
<LayersPanel
open={panels.layers}
onClose={() => setPanels((p) => ({ ...p, layers: false }))}
anchorRef={stageRef}
layers={layers}
onToggle={onToggleLayer}
onShowAll={onShowAll}
onHideAll={onHideAll}
/>
<PropertiesPanel
open={panels.props}
onClose={() => setPanels((p) => ({ ...p, props: false }))}
anchorRef={stageRef}
entity={selected}
onZoomToSelection={() => viewerRef.current?.zoomToSelection()}
/>
<OptionsPanel
open={panels.options}
onClose={() => setPanels((p) => ({ ...p, options: false }))}
anchorRef={stageRef}
engine={engine}
grid={prefs.grid}
view25D={view25D}
onLineweightEnabled={onLineweightEnabled}
onLineweightScale={onLineweightScale}
onGrid={onGrid}
onZoomSpeed={onZoomSpeed}
on25D={apply25D}
onZScale={onZScale}
onReset={onReset}
/>
</div>
<StatusBar status={status} pointer={pointer} zoom={zoom} onNotices={openAbout} />
</main>
{/* HM Input has no file-picker look of its own here; it stays hidden and the GNB / empty-state buttons open it. */}
<Input ref={fileRef} type="file" accept=".dwg,.dxf" hidden aria-label="DWG / DXF 파일 선택" onChange={onFileChange} />
<AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} returnFocusRef={aboutOpener} />
<Toaster />
</div>
</TooltipProvider>
)
}