"use client"; import * as React from "react"; import { animate, motion, useDragControls, useMotionValue, type MotionValue, type PanInfo, } from "motion/react"; import { panelDragHandleSelector, panelDragIgnoredTargetSelector, panelDragTransition, panelHostConfig, panelSnapAnimation, } from "./panel-host-config"; import { clampPanelPlacementToViewport, resolvePanelSnapPlacement, } from "./panel-host-geometry"; import type { ToolcraftPanelHostProps, PanelContainerProps, PanelHostProps, PanelPoint, PanelSnapPlacement, PanelStageProps, PanelViewport, } from "./panel-host-types"; import { useToolcraftPanelBinding } from "./use-toolcraft-panel-binding"; function cn(...classNames: Array): string { return classNames.filter(Boolean).join(" "); } function isPanelDragHandleTarget(target: EventTarget | null, currentTarget: HTMLElement): boolean { if (!(target instanceof Element)) { return false; } const handleTarget = target.closest(panelDragHandleSelector); return handleTarget !== null && currentTarget.contains(handleTarget); } function shouldIgnorePanelTarget(target: EventTarget | null, currentTarget: HTMLElement): boolean { if (!(target instanceof Element)) { return false; } const ignoredTarget = target.closest(panelDragIgnoredTargetSelector); return ignoredTarget !== null && currentTarget.contains(ignoredTarget); } function shouldIgnorePanelDrag(event: React.PointerEvent): boolean { return shouldIgnorePanelTarget(event.target, event.currentTarget); } function getPanelVisualViewport(): PanelViewport { const visualViewport = window.visualViewport; if (visualViewport) { return { height: visualViewport.height, offsetLeft: visualViewport.offsetLeft, offsetTop: visualViewport.offsetTop, width: visualViewport.width, }; } return { height: window.innerHeight, offsetLeft: 0, offsetTop: 0, width: window.innerWidth, }; } function animatePanelMotionValue(value: MotionValue, target: number): void { void animate(value, target, panelSnapAnimation); } function usePanelSnapControls({ onPlacementChange, onPositionChange, onResetPosition, position = { x: 0, y: 0 }, snap, snapEdge, }: Pick< PanelHostProps, | "onPlacementChange" | "onPositionChange" | "onResetPosition" | "position" | "snap" | "snapEdge" >) { const panelRef = React.useRef(null); const x = useMotionValue(position.x); const y = useMotionValue(position.y); React.useEffect(() => { animatePanelMotionValue(x, position.x); animatePanelMotionValue(y, position.y); }, [position.x, position.y, x, y]); const publishPlacement = React.useCallback( (placement: PanelSnapPlacement): void => { onPositionChange?.(placement.offset); onPlacementChange?.(placement); }, [onPlacementChange, onPositionChange], ); const setPlacement = React.useCallback( (placement: PanelSnapPlacement): void => { animatePanelMotionValue(x, placement.offset.x); animatePanelMotionValue(y, placement.offset.y); publishPlacement(placement); }, [publishPlacement, x, y], ); const publishPosition = (nextPosition: PanelPoint): void => { publishPlacement({ offset: nextPosition, snapEdge: undefined }); }; const publishCurrentPosition = (): void => { publishPosition({ x: x.get(), y: y.get() }); }; const handleDragEnd = (info: PanInfo): void => { if (!snap || snap.edges.length === 0) { publishCurrentPosition(); return; } const panel = panelRef.current; if (!panel) { publishCurrentPosition(); return; } const rect = panel.getBoundingClientRect(); const offset = { x: x.get(), y: y.get() }; const target = resolvePanelSnapPlacement({ dimensions: { height: rect.height, width: rect.width }, edges: snap.edges, margin: snap.margin, position: { x: rect.left, y: rect.top }, velocity: { x: info.velocity.x / 1000, y: info.velocity.y / 1000 }, viewport: getPanelVisualViewport(), zone: snap.zone, }); if (!target) { publishCurrentPosition(); return; } const nextPosition = { x: target.offset.x - (rect.left - offset.x), y: target.offset.y - (rect.top - offset.y), }; setPlacement({ offset: nextPosition, snapEdge: target.snapEdge }); }; const resetPosition = (): void => { setPlacement({ offset: { x: 0, y: 0 }, snapEdge: undefined }); onResetPosition?.(); }; React.useLayoutEffect(() => { const panel = panelRef.current; if (!panel) { return undefined; } const clampRestoredPlacement = (): void => { const rect = panel.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return; } if (x.get() === 0 && y.get() === 0 && snapEdge === undefined) { return; } const currentPlacement = { offset: { x: x.get(), y: y.get() }, ...(snapEdge ? { snapEdge } : {}), }; const nextPlacement = clampPanelPlacementToViewport({ dimensions: { height: rect.height, width: rect.width }, margin: snap?.margin, origin: { x: rect.left - currentPlacement.offset.x, y: rect.top - currentPlacement.offset.y, }, placement: currentPlacement, viewport: getPanelVisualViewport(), }); if ( nextPlacement.offset.x !== currentPlacement.offset.x || nextPlacement.offset.y !== currentPlacement.offset.y ) { setPlacement(nextPlacement); } }; clampRestoredPlacement(); window.addEventListener("resize", clampRestoredPlacement); return () => { window.removeEventListener("resize", clampRestoredPlacement); }; }, [setPlacement, snap?.margin, snapEdge, x, y]); return { handleDragEnd, panelRef, resetPosition, x, y }; } export function PanelHost({ children, className, dragMode, innerClassName, onPositionChange, onPlacementChange, onResetPosition, panelId, panelType, position, snap, snapEdge, style, }: PanelHostProps): React.JSX.Element { const config = panelHostConfig[panelType]; const resolvedDragMode = dragMode ?? config.dragMode; const resolvedSnap = snap ?? { edges: config.snapEdges }; const resolvedPanelId = panelId ?? config.panelId; const dragControls = useDragControls(); const { handleDragEnd: handleSnapDragEnd, panelRef, resetPosition, x, y, } = usePanelSnapControls({ onPositionChange, onPlacementChange, onResetPosition, position, snap: resolvedSnap, snapEdge, }); const [isDragging, setIsDragging] = React.useState(false); const handlePointerDown: React.PointerEventHandler = (event) => { if (event.button !== 0) { return; } if ( resolvedDragMode === "handle" && !isPanelDragHandleTarget(event.target, event.currentTarget) ) { return; } if (event.defaultPrevented || shouldIgnorePanelDrag(event)) { return; } event.preventDefault(); event.stopPropagation(); dragControls.start(event); }; const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo): void => { setIsDragging(false); handleSnapDragEnd(info); }; const handleDoubleClick: React.MouseEventHandler = (event) => { if ( !resolvedSnap || event.defaultPrevented || (resolvedDragMode === "handle" && !isPanelDragHandleTarget(event.target, event.currentTarget)) || shouldIgnorePanelTarget(event.target, event.currentTarget) ) { return; } resetPosition(); }; return (
setIsDragging(true)} onPointerDown={handlePointerDown} ref={panelRef} style={{ x, y }} > {children}
); } export function ToolcraftPanelHost({ onPlacementChange, onPositionChange, onResetPosition, panelType, position, snapEdge, ...props }: ToolcraftPanelHostProps): React.JSX.Element { const panelBinding = useToolcraftPanelBinding({ panelId: panelType }); return ( { onPlacementChange?.(placement); panelBinding.onPanelStateChange(placement); }} onPositionChange={(offset) => { onPositionChange?.(offset); }} onResetPosition={() => { onResetPosition?.(); }} panelType={panelType} position={position ?? panelBinding.panelState.offset} snapEdge={snapEdge ?? panelBinding.panelState.snapEdge} /> ); } export function PanelStage({ children, className, ...props }: PanelStageProps): React.JSX.Element { return (
{children}
); } export function PanelContainer({ children, className, dragMode, onPanelStateChange, panelClassName, panelState, panelType, placement, ...props }: PanelContainerProps): React.JSX.Element { const config = panelHostConfig[panelType]; if (placement === "surface") { return <>{children}; } if (placement === "floating") { return ( {children} ); } return ( {children} ); }