Files
dwg-dxf-viewer-hm/src/components/FloatingPanel.tsx
T
minsungandClaude Opus 5.5 320233f6d7 fix(ui): open/close tool windows with the hidden attribute
panelUtils owns the window's inline left/top/z-index/height, so React
should not manage its style at all; use `hidden` (+ a CSS rule against
the frame's display:flex) instead of style.display (#1). Also drops a
dynamic style value the HMDesign guard reported as unverifiable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:57:53 +09:00

118 lines
4.8 KiB
TypeScript

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; panelUtils also owns its inline left/top/z-index/height. Open/closed
* is the `hidden` attribute, so neither of those is touched.
*/
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}
hidden={!open}
>
<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>
)
}