import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { calculateToolbarTargets, NODEDC_TOOLBAR_DEFAULTS, normalizeToolbarSettings, stepToolbarSpring, type ToolbarPlacement as CoreToolbarPlacement, } from "@nodedc/ui-core"; import { cn } from "./cn.js"; import { Icon, type IconName } from "./Icon.js"; export type ToolbarPlacement = CoreToolbarPlacement; export interface ToolbarItem { id: T; label: string; icon: IconName; active?: boolean; disabled?: boolean; onSelect: (id: T) => void; } export interface ToolbarProps { items: Array>; placement?: ToolbarPlacement; background?: string; border?: string; outline?: string; accent?: string; minSize?: number; maxSize?: number; lensCount?: number; autoHide?: boolean; label?: string; className?: string; } interface ToolbarMotion { scale: number; targetScale: number; scaleVelocity: number; shift: number; targetShift: number; shiftVelocity: number; } export function Toolbar({ items, placement = "left", background = NODEDC_TOOLBAR_DEFAULTS.background, border = NODEDC_TOOLBAR_DEFAULTS.border, outline = NODEDC_TOOLBAR_DEFAULTS.outline, accent = "#ff2f92", minSize = NODEDC_TOOLBAR_DEFAULTS.minSize, maxSize = NODEDC_TOOLBAR_DEFAULTS.maxSize, lensCount = NODEDC_TOOLBAR_DEFAULTS.lensCount, autoHide = false, label = "Панель инструментов", className, }: ToolbarProps) { const [visible, setVisible] = useState(!autoHide); const [dockActive, setDockActive] = useState(false); const toolbarRef = useRef(null); const buttonRefs = useRef>({}); const motionRef = useRef>({}); const actionKeysRef = useRef([]); const frameRef = useRef(null); const frameLastTsRef = useRef(0); const hideTimerRef = useRef(null); const hoverDockRef = useRef(false); const vertical = placement !== "bottom"; const normalized = normalizeToolbarSettings({ minSize, maxSize, lensCount }); const safeMin = normalized.minSize; const safeMax = normalized.maxSize; const safeLens = normalized.lensCount; const radius = Math.max(7, Math.round(safeMin * 0.28)); const glyphSize = Math.max(12, Math.min(18, Math.round(safeMin * 0.62))); const settingsRef = useRef({ minSize: safeMin, maxSize: safeMax, lensCount: safeLens, vertical }); settingsRef.current = { minSize: safeMin, maxSize: safeMax, lensCount: safeLens, vertical }; actionKeysRef.current = items.map((item) => String(item.id)); const getMotion = useCallback((key: string) => { let motion = motionRef.current[key]; if (!motion) { motion = { scale: 1, targetScale: 1, scaleVelocity: 0, shift: 0, targetShift: 0, shiftVelocity: 0, }; motionRef.current[key] = motion; } return motion; }, []); const renderFrame = useCallback((timestamp = performance.now()) => { const keys = actionKeysRef.current; if (!keys.length) { frameRef.current = null; return; } const dt = frameLastTsRef.current ? Math.min(0.032, Math.max(0.001, (timestamp - frameLastTsRef.current) / 1000)) : 0.016; frameLastTsRef.current = timestamp; let moving = false; keys.forEach((key) => { const button = buttonRefs.current[key]; if (!button) return; const motion = getMotion(key); [motion.scale, motion.scaleVelocity] = stepToolbarSpring( motion.scale, motion.scaleVelocity, motion.targetScale, dt, ); [motion.shift, motion.shiftVelocity] = stepToolbarSpring( motion.shift, motion.shiftVelocity, motion.targetShift, dt, ); if ( Math.abs(motion.scale - motion.targetScale) < 0.001 && Math.abs(motion.scaleVelocity) < 0.001 && Math.abs(motion.shift - motion.targetShift) < 0.05 && Math.abs(motion.shiftVelocity) < 0.05 ) { motion.scale = motion.targetScale; motion.scaleVelocity = 0; motion.shift = motion.targetShift; motion.shiftVelocity = 0; } else { moving = true; } button.style.setProperty("--nodedc-toolbar-scale", motion.scale.toFixed(4)); button.style.setProperty("--nodedc-toolbar-shift", `${motion.shift.toFixed(2)}px`); }); if (moving) { frameRef.current = window.requestAnimationFrame(renderFrame); return; } frameRef.current = null; frameLastTsRef.current = 0; }, [getMotion]); const startAnimation = useCallback(() => { if (frameRef.current != null) return; frameLastTsRef.current = 0; frameRef.current = window.requestAnimationFrame(renderFrame); }, [renderFrame]); const setButtonTarget = useCallback((key: string, size: number, shift: number) => { const motion = getMotion(key); const { minSize: currentMin } = settingsRef.current; motion.targetScale = currentMin > 0 ? size / currentMin : 1; motion.targetShift = shift; startAnimation(); }, [getMotion, startAnimation]); const resetMagnification = useCallback(() => { const { minSize: currentMin } = settingsRef.current; actionKeysRef.current.forEach((key) => setButtonTarget(key, currentMin, 0)); setDockActive(false); }, [setButtonTarget]); const updateMagnification = useCallback((pointerCoordinate: number) => { const toolbar = toolbarRef.current; const keys = actionKeysRef.current; if (!toolbar || !keys.length) return; const { minSize: currentMin, maxSize: currentMax, lensCount: currentLens, vertical: isVertical } = settingsRef.current; const toolbarRect = toolbar.getBoundingClientRect(); const entries = keys .map((key) => { const button = buttonRefs.current[key]; if (!button) return null; const offset = isVertical ? button.offsetTop : button.offsetLeft; const length = isVertical ? button.offsetHeight : button.offsetWidth; const origin = isVertical ? toolbarRect.top : toolbarRect.left; return { key, center: origin + offset + length / 2 }; }) .filter((entry): entry is { key: string; center: number } => Boolean(entry)) .sort((a, b) => a.center - b.center); const targets = calculateToolbarTargets( entries.map((entry) => entry.center), pointerCoordinate, { minSize: currentMin, maxSize: currentMax, lensCount: currentLens, gap: NODEDC_TOOLBAR_DEFAULTS.gap }, ); entries.forEach(({ key }, index) => { const target = targets[index]; if (target) setButtonTarget(key, target.size, target.shift); }); }, [setButtonTarget]); const showDock = useCallback(() => { if (hideTimerRef.current != null) { window.clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } setVisible(true); }, []); const scheduleHide = useCallback(() => { if (!autoHide || hideTimerRef.current != null) return; hideTimerRef.current = window.setTimeout(() => { hideTimerRef.current = null; if (hoverDockRef.current) return; setVisible(false); resetMagnification(); }, NODEDC_TOOLBAR_DEFAULTS.hideDelay); }, [autoHide, resetMagnification]); useEffect(() => { if (!autoHide) { setVisible(true); return; } setVisible(false); setDockActive(false); }, [autoHide]); useEffect(() => { resetMagnification(); }, [placement, safeMin, safeMax, safeLens, resetMagnification]); useEffect(() => { if (!autoHide) return; const handlePointerMove = (event: PointerEvent) => { const threshold = Math.max(32, settingsRef.current.maxSize * 0.75); const nearEdge = placement === "left" ? event.clientX <= threshold : placement === "right" ? event.clientX >= window.innerWidth - threshold : event.clientY >= window.innerHeight - threshold; if (nearEdge) showDock(); else if (!hoverDockRef.current) scheduleHide(); }; window.addEventListener("pointermove", handlePointerMove, { passive: true }); return () => window.removeEventListener("pointermove", handlePointerMove); }, [autoHide, placement, scheduleHide, showDock]); useEffect(() => { const handlePointerMove = (event: PointerEvent) => { const toolbar = toolbarRef.current; if (!hoverDockRef.current || !toolbar) return; const rect = toolbar.getBoundingClientRect(); const inside = event.clientX >= rect.left - 2 && event.clientX <= rect.right + 2 && event.clientY >= rect.top - 2 && event.clientY <= rect.bottom + 2; if (inside) return; hoverDockRef.current = false; resetMagnification(); scheduleHide(); }; window.addEventListener("pointermove", handlePointerMove, { passive: true }); return () => window.removeEventListener("pointermove", handlePointerMove); }, [resetMagnification, scheduleHide]); useEffect(() => () => { if (frameRef.current != null) window.cancelAnimationFrame(frameRef.current); if (hideTimerRef.current != null) window.clearTimeout(hideTimerRef.current); }, []); const style = { "--nodedc-toolbar-bg": background, "--nodedc-toolbar-border": border, "--nodedc-toolbar-outline": outline, "--nodedc-toolbar-accent": accent, "--nodedc-toolbar-min-size": `${safeMin}px`, "--nodedc-toolbar-max-size": `${safeMax}px`, "--nodedc-toolbar-radius": `${radius}px`, "--nodedc-toolbar-gap": `${NODEDC_TOOLBAR_DEFAULTS.gap}px`, "--nodedc-toolbar-padding": `${NODEDC_TOOLBAR_DEFAULTS.padding}px`, } as CSSProperties; return (
{autoHide ? ( ); }