import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react" import { SWPopup } from "../sw-popup/SWPopup" import "../../theme/sw-viewer.css" export type SWViewerPanelPlacement = "top-end" | "top-start" | "center" export interface SWViewerPanelProps extends Omit, "title"> { /** Title (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */ title: string /** Shown when true. Unmounted otherwise (no hidden DOM). */ open: boolean /** Close button, and Escape (inside the panel, or anywhere while it is the top SW popup — SWPopup behavior). */ onClose: () => void closeLabel?: string /** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */ headerActions?: ReactNode /** Free body content: SWViewerSection / SWViewerField / lists / any component. The body scrolls. */ children?: ReactNode /** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */ footer?: ReactNode /** Initial size. Default 314 (the SWPopup width) x 420. `height: "auto"` follows the content. */ width?: number height?: number | "auto" minWidth?: number minHeight?: number /** Initial position: a point, or a placement inside the bounds. Default `top-end`. */ defaultPosition?: { left: number; top: number } | SWViewerPanelPlacement /** Placement insets in px. Default `{ top: 56, side: 24 }` (below the 52px viewer header). */ inset?: { top?: number; side?: number } /** `fixed` (default): the viewport. `absolute`: the nearest positioned ancestor (embedded preview). */ strategy?: "fixed" | "absolute" /** Drag by the title bar. Default true. */ draggable?: boolean /** Corner grip resize (min / max size, kept inside the bounds). Default true. */ resizable?: boolean /** Collapse button and title double click. Default true. */ collapsible?: boolean defaultCollapsed?: boolean collapsed?: boolean onCollapsedChange?: (collapsed: boolean) => void /** Dim backdrop, focus trap and centered default (input / confirm popups). Default false. */ modal?: boolean /** Focus the panel when it opens; focus returns to the previous element when it closes. Default true. */ autoFocus?: boolean onPositionChange?: (position: { left: number; top: number }) => void /** After a corner resize. */ onSizeChange?: (size: { width: number; height: number }) => void } let topLayer = 40 const EDGE = 10 const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]" const Chevron = ({ down }: { down: boolean }) => ( ) function bounds(element: HTMLElement, strategy: "fixed" | "absolute") { const parent = strategy === "absolute" ? element.offsetParent as HTMLElement | null : null return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight } } function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed" | "absolute") { const box = bounds(element, strategy) return { left: Math.round(Math.min(Math.max(left, EDGE), Math.max(EDGE, box.width - element.offsetWidth - EDGE))), top: Math.round(Math.min(Math.max(top, EDGE), Math.max(EDGE, box.height - element.offsetHeight - EDGE))), } } /** * SW viewer popup / settings window. The frame is the common SW popup (`SWPopup`: title bar, title, close, * border, radius, shadow — the same component and DOM as every SW popup); this adds the viewer window behavior: * drag by the title bar, corner resize, collapse, placement inside the bounds, bring to front, optional modal. * `children` / `footer` are free content. */ export function SWViewerPanel({ title, open, onClose, closeLabel = "닫기", headerActions, children, footer, width = 314, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed", draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange, modal = false, autoFocus = true, onPositionChange, onSizeChange, id: idProp, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest }: SWViewerPanelProps) { const generatedId = useId() const id = idProp ?? `hm-sw-viewer-panel-${generatedId.replace(/:/g, "")}` const element = () => document.getElementById(id) as HTMLDivElement | null const [position, setPosition] = useState<{ left: number; top: number } | null>(null) const [layer, setLayer] = useState(() => ++topLayer) const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed) const collapsed = collapsible && (collapsedProp ?? internalCollapsed) const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null) // Size after a corner resize (null: the width / height props). const [size, setSize] = useState<{ width: number; height: number } | null>(null) const resize = useRef<{ x: number; y: number; width: number; height: number } | null>(null) const positionRef = useRef(position) positionRef.current = position const placement = defaultPosition ?? (modal ? "center" : "top-end") // Initial position, measured once per opening (before paint); then kept inside the bounds when they change. useLayoutEffect(() => { if (!open) { setPosition(null); setSize(null); return } const panel = element() if (!panel) return const box = bounds(panel, strategy) const top = inset?.top ?? 56, side = inset?.side ?? 24 const start = typeof placement === "object" ? placement : placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 } : { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top } setPosition(clamp(start.left, start.top, panel, strategy)) const keepInside = () => { const current = positionRef.current, node = element(); if (current && node) setPosition(clamp(current.left, current.top, node, strategy)) } window.addEventListener("resize", keepInside) const parent = strategy === "absolute" ? panel.offsetParent : null const observer = parent ? new ResizeObserver(keepInside) : null if (parent) observer?.observe(parent) return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() } }, [open]) if (!open) return null const raise = () => { if (layer !== topLayer) setLayer(++topLayer) } const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) } const onTitlebar = (target: EventTarget) => { const node = target as HTMLElement return !!node.closest(".hm-sw-property__titlebar") && !node.closest(INTERACTIVE) } const startDrag = (event: ReactPointerEvent) => { onPointerDown?.(event) if (!draggable || event.button !== 0 || !position || !onTitlebar(event.target)) return drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top } event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.dataset.dragging = "" } const moveDrag = (event: ReactPointerEvent) => { onPointerMove?.(event) const start = drag.current, panel = element() if (!start || !panel) return setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, panel, strategy)) } const endDrag = (event: ReactPointerEvent) => { onPointerUp?.(event) if (!drag.current) return drag.current = null delete event.currentTarget.dataset.dragging if (positionRef.current) onPositionChange?.(positionRef.current) } const toggleByTitle = (event: ReactMouseEvent) => { onDoubleClick?.(event) // Pointer capture (drag) retargets the click to the panel root, so test what is under the pointer. const under = document.elementFromPoint(event.clientX, event.clientY) if (collapsible && under && onTitlebar(under)) setCollapsed(!collapsed) } const onGripPointerDown = (event: ReactPointerEvent) => { const panel = element() if (event.button !== 0 || !panel) return event.preventDefault() event.stopPropagation() resize.current = { x: event.clientX, y: event.clientY, width: panel.offsetWidth, height: panel.offsetHeight } event.currentTarget.setPointerCapture(event.pointerId) } const onGripPointerMove = (event: ReactPointerEvent) => { event.stopPropagation() const start = resize.current, panel = element() if (!start || !panel || !position) return const box = bounds(panel, strategy) // Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner. const maxWidth = Math.min(box.width * 0.9, box.width - position.left - EDGE), maxHeight = Math.min(box.height * 0.9, box.height - position.top - EDGE) setSize({ width: Math.round(Math.max(minWidth, Math.min(maxWidth, start.width + event.clientX - start.x))), height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))), }) } const onGripPointerUp = (event: ReactPointerEvent) => { event.stopPropagation() if (!resize.current) return resize.current = null const panel = element() if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight }) } const trapFocus = (event: KeyboardEvent) => { onKeyDown?.(event) const panel = element() if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return const focusables = Array.from(panel.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])')) if (!focusables.length) { event.preventDefault(); return } const first = focusables[0], last = focusables[focusables.length - 1] if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } } const currentWidth = size?.width ?? width const frameStyle: CSSProperties = { position: strategy, left: position?.left ?? 0, top: position?.top ?? 0, zIndex: layer, width: currentWidth, height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height, minWidth, minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight), maxWidth: "90vw", maxHeight: "90vh", ["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`, ...style, } const panel = ( { raise(); onPointerDownCapture?.(event) }} onFocusCapture={event => { raise(); onFocusCapture?.(event) }} onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle} titleActions={(headerActions || collapsible) &&
{headerActions} {collapsible && }
} footer={<> {footer && !collapsed &&
{footer}
} {resizable && !collapsed &&