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>
118 lines
4.8 KiB
TypeScript
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>
|
|
)
|
|
}
|