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

+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"
import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react"
import { Button, Card, CardAction, CardHeader, CardTitle, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui"
import { makePanelDraggableAndCollapsible } from "../engine/panelUtils"
type Props = {
title: string
icon: ReactNode
/** Extra header content after the title (count badge…). */
meta?: ReactNode
/** Extra header buttons before collapse / close. */
actions?: ReactNode
open: boolean
onClose: () => void
/** Initial slot from the right edge of the anchor (0 = right-most). */
slot: number
/** Area the window is first placed in (the canvas stage). */
anchorRef: RefObject<HTMLElement | null>
/** Fixed starting height; omitted = fit content (capped by CSS). */
size?: "tall" | "auto"
children: ReactNode
}
const GAP = 12
/**
* A floating tool window over the canvas: HM Card inside a positioned frame.
*
* Drag (header), collapse (header double-click / button) and focus-to-front come from the ported
* `makePanelDraggableAndCollapsible` (src/engine/panelUtils.ts, unchanged); resizing is CSS `resize: both` on the
* frame, as in the sample. The frame keeps a static className because panelUtils toggles `collapsed` on it, and
* React would overwrite a changing className. Open/closed only touches `style.display`.
*/
export function FloatingPanel({ title, icon, meta, actions, open, onClose, slot, anchorRef, size = "tall", children }: Props) {
const frameRef = useRef<HTMLElement>(null)
const headerRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const control = useRef<{ toggleCollapse: () => void } | null>(null)
const placed = useRef(false)
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
const frame = frameRef.current
// panelUtils has no teardown; attach once per element (StrictMode runs effects twice in development,
// and two dblclick listeners would cancel each other out).
if (!frame || frame.dataset.panelReady) return
frame.dataset.panelReady = "1"
control.current = makePanelDraggableAndCollapsible(frame, {
handleEl: headerRef.current,
bodyEl: bodyRef.current,
onCollapseToggle: setCollapsed,
})
}, [])
// First open: place the window inside the canvas stage, stacked from the right edge.
useLayoutEffect(() => {
const frame = frameRef.current
const anchor = anchorRef.current
if (!open || placed.current || !frame || !anchor) return
placed.current = true
const r = anchor.getBoundingClientRect()
const w = frame.offsetWidth
const left = Math.max(r.left + GAP, r.right - GAP - (slot + 1) * w - slot * GAP)
frame.style.left = `${Math.round(left)}px`
frame.style.top = `${Math.round(r.top + GAP)}px`
}, [open, slot, anchorRef])
return (
<section
ref={frameRef}
className="viewer-window"
data-size={size}
aria-label={title}
style={{ display: open ? undefined : "none" }}
>
<Card size="sm" className="viewer-window-card">
<CardHeader ref={headerRef} className="viewer-window-header border-b">
<CardTitle className="flex min-w-0 items-center gap-2">
<span aria-hidden="true" className="flex text-muted-foreground">{icon}</span>
<span className="truncate">{title}</span>
{meta}
</CardTitle>
<CardAction className="flex items-center gap-1">
{actions}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={collapsed ? `${title} 펼치기` : `${title} 접기`}
aria-expanded={!collapsed}
onClick={() => control.current?.toggleCollapse()}
>
{collapsed ? <ChevronRightIcon aria-hidden="true" /> : <ChevronDownIcon aria-hidden="true" />}
</Button>
</TooltipTrigger>
<TooltipContent>{collapsed ? "펼치기" : "접기"} (제목 더블클릭)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`${title} 닫기`} onClick={onClose}>
<XIcon aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>닫기</TooltipContent>
</Tooltip>
</CardAction>
</CardHeader>
<div ref={bodyRef} className="viewer-window-body">
{children}
</div>
</Card>
</section>
)
}