UNPKG

react-native-leader-line

Version:

React Native port of leader-line library for drawing arrow lines and connectors

560 lines 26.1 kB
/** * @fileoverview React Native Leader Line Component * @description Main LeaderLine component optimized for LLM consumption with comprehensive JSDoc * @version 1.1.0 * @author Federico Garcia * * @example Basic Usage * ```tsx * import React, { useRef } from 'react'; * import { View } from 'react-native'; * import { LeaderLine } from 'react-native-leader-line'; * * const MyComponent = () => { * const startRef = useRef(null); * const endRef = useRef(null); * * return ( * <View> * <View ref={startRef} style={{ width: 100, height: 50 }} /> * <View ref={endRef} style={{ width: 100, height: 50 }} /> * <LeaderLine * start={{ element: startRef }} * end={{ element: endRef }} * color="#3498db" * strokeWidth={3} * endPlug="arrow1" * /> * </View> * ); * }; * ``` * * @example Advanced Styling * ```tsx * <LeaderLine * start={{ element: startRef }} * end={{ element: endRef }} * color="#e74c3c" * strokeWidth={4} * path="arc" * curvature={0.3} * endPlug="arrow2" * outline={{ enabled: true, color: "white", size: 2 }} * dropShadow={{ dx: 2, dy: 2, blur: 4, color: "rgba(0,0,0,0.3)" }} * startLabel="Begin" * endLabel="End" * /> * ``` */ import * as React from "react"; const { useCallback, useEffect, useMemo, useState } = React; import { Text, View } from "react-native"; import Svg, { Defs, Marker, Path, Text as SvgText } from "react-native-svg"; import { useMultipleLabels } from "../hooks/useMultipleLabels"; import { LIBRARY_VERSION, BUILD_TIMESTAMP } from "../version"; import { areaAnchor, calculateConnectionPoints, calculatePathBoundingBoxWithOutline, createEnhancedPlugPath, generateDashArray, generateEnhancedPathData, generateSmoothBezierPath, mouseHoverAnchor, normalizeOutlineOptions, normalizePlugOutlineOptions, pointAnchor, } from "../utils/math"; /** * @component LeaderLine * @description A React Native component for drawing arrow lines and connectors between UI elements. * This component provides a powerful and flexible way to create visual connections in mobile apps. * * @param {LeaderLineProps} props - Component props * @param {Attachment} props.start - Starting attachment point (required) * @param {Attachment} props.end - Ending attachment point (required) * @param {SocketPosition} [props.startSocket="center"] - Where the line connects to the start element * @param {SocketPosition} [props.endSocket="center"] - Where the line connects to the end element * @param {string} [props.color="#ff6b6b"] - Line color (CSS color string) * @param {number} [props.strokeWidth=2] - Line thickness in pixels * @param {number} [props.size] - Legacy alias for strokeWidth (leader-line compatibility) * @param {number} [props.opacity=1] - Line opacity (0-1) * @param {PathType|PathConfiguration} [props.path="straight"] - Line path type * @param {number} [props.curvature=0.2] - Curve amount for arc paths (0-1) * @param {PlugType} [props.startPlug="none"] - Start marker type * @param {PlugType} [props.endPlug="arrow1"] - End marker type * @param {string} [props.startPlugColor] - Custom color for start marker * @param {string} [props.endPlugColor] - Custom color for end marker * @param {number} [props.startPlugSize=10] - Start marker size * @param {number} [props.endPlugSize=10] - End marker size * @param {boolean|DashOptions} [props.dash] - Dash pattern configuration * @param {boolean|OutlineOptions} [props.outline] - Line outline configuration * @param {boolean|PlugOutlineOptions} [props.startPlugOutline] - Start marker outline * @param {boolean|PlugOutlineOptions} [props.endPlugOutline] - End marker outline * @param {boolean|DropShadowOptions} [props.dropShadow=false] - Drop shadow configuration * @param {LabelOptions} [props.label] - Simple label configuration * @param {ViewStyle} [props.style] - Container style * @param {React.ReactNode} [props.children] - Child components * @param {string|EnhancedLabelOptions} [props.startLabel] - Label at line start * @param {string|EnhancedLabelOptions} [props.middleLabel] - Label at line middle * @param {string|EnhancedLabelOptions} [props.endLabel] - Label at line end * @param {string|EnhancedLabelOptions} [props.captionLabel] - Caption label * @param {string|EnhancedLabelOptions} [props.pathLabel] - Path label * * @returns {React.ReactElement|null} The rendered LeaderLine component * * @since 1.0.0 * @public */ export const LeaderLine = React.memo(({ // Required props start, end, // Socket configuration startSocket = "center", endSocket = "center", // Basic styling color = "#ff6b6b", strokeWidth = 2, size, // Legacy alias for strokeWidth (leader-line compatibility) opacity = 1, // Path configuration path = "straight", curvature = 0.2, // Plug/marker configuration startPlug = "none", endPlug = "arrow1", startPlugColor, endPlugColor, startPlugSize = 10, endPlugSize = 10, // Advanced styling dash, outline, startPlugOutline, endPlugOutline, dropShadow = false, // Labels label, startLabel, middleLabel, endLabel, captionLabel, pathLabel, // Animation properties animation, animationDuration = 300, animationEasing: _animationEasing, animationDelay = 0, animationReverse: _animationReverse = false, animationPaused = false, animationRestart = false, animationLoop = false, animationLoopCount, animationDirection: _animationDirection = "right", animationFromOpacity: _animationFromOpacity = 0, animationToOpacity: _animationToOpacity = 1, animationBounceHeight: _animationBounceHeight = 10, animationElasticity: _animationElasticity = 0.5, // Animation callbacks onAnimationStart, onAnimationEnd, onAnimationIteration, // Container props style, children, // Testing props testID, // Performance optimization props optimizeUpdates = true, updateThreshold: _updateThreshold = 5, // Minimum pixel change to trigger update }) => { // Safety check for React hooks availability if (!React || !useEffect || !useState || !useMemo || !useCallback) { console.error("LeaderLine: React hooks not available. Please check React import."); return null; } // Library version logging for debugging useEffect(() => { console.log(`🚀 LeaderLine Component Loaded - Version: ${LIBRARY_VERSION} | Build: ${BUILD_TIMESTAMP}`); console.log("📦 react-native-leader-line: Library is properly loaded and useState should be working"); }, []); // Guard against missing required props if (!start || !end) { console.warn("LeaderLine: start and end props are required"); return null; } // Handle legacy 'size' prop (leader-line compatibility) const effectiveStrokeWidth = size !== undefined ? size : strokeWidth; // Internal state for connection points and SVG bounds const [startPoint, setStartPoint] = useState(null); const [endPoint, setEndPoint] = useState(null); const [svgBounds, setSvgBounds] = useState({ x: 0, y: 0, width: 400, height: 300, }); // Animation state const [_animationState, _setAnimationState] = useState({ isAnimating: false, currentIteration: 0, }); // Performance optimization: throttle updates const [lastUpdateTime, setLastUpdateTime] = useState(0); const updateThrottleDelay = 16; // ~60fps /** * @description Handle animation start */ const handleAnimationStart = useCallback(() => { _setAnimationState((prev) => ({ ...prev, isAnimating: true })); if (onAnimationStart) { // Schedule callback to run after a small delay to ensure component is ready setTimeout(() => { onAnimationStart(); }, 0); } }, [onAnimationStart]); /** * @description Handle animation end */ const handleAnimationEnd = useCallback(() => { _setAnimationState((prev) => ({ ...prev, isAnimating: false })); if (onAnimationEnd) { setTimeout(() => { onAnimationEnd(); }, 0); } }, [onAnimationEnd]); /** * @description Handle animation iteration for loops */ const handleAnimationIteration = useCallback(() => { _setAnimationState((prev) => ({ ...prev, currentIteration: prev.currentIteration + 1, })); if (onAnimationIteration) { setTimeout(() => { onAnimationIteration(); }, 0); } }, [onAnimationIteration]); /** * @description Start animation when component mounts or animation props change */ useEffect(() => { // Safety check for useEffect availability if (!useEffect) { console.error("LeaderLine: useEffect not available in animation effect"); return; } let isMounted = true; let timer = null; let loopTimer = null; try { if (animation && !animationPaused && isMounted) { handleAnimationStart(); // Simulate animation duration timer = setTimeout(() => { if (isMounted && !animationLoop) { handleAnimationEnd(); } }, animationDuration + animationDelay); // Handle animation loops if (animationLoop) { const maxIterations = animationLoopCount || 100; // Default to finite loops for testing let currentIteration = 0; loopTimer = setInterval(() => { if (!isMounted) return; currentIteration++; handleAnimationIteration(); // Stop if we've reached the max iterations or if loopCount is explicitly set if (animationLoopCount && currentIteration >= animationLoopCount) { if (loopTimer) clearInterval(loopTimer); if (isMounted) handleAnimationEnd(); } else if (!animationLoopCount && currentIteration >= maxIterations) { // Safety net to prevent infinite loops in tests if (loopTimer) clearInterval(loopTimer); if (isMounted) handleAnimationEnd(); } }, animationDuration + animationDelay); } } } catch (error) { console.error("LeaderLine: Error in animation effect:", error); } return () => { isMounted = false; if (timer) { clearTimeout(timer); } if (loopTimer) { clearInterval(loopTimer); } }; }, [ animation, animationDuration, animationDelay, animationPaused, animationLoop, animationLoopCount, animationRestart, handleAnimationStart, handleAnimationEnd, handleAnimationIteration, ]); /** * @description Calculate connection points between start and end elements/points * This effect runs whenever the start, end, or socket positions change */ useEffect(() => { // Safety check for useEffect availability if (!useEffect) { console.error("LeaderLine: useEffect not available in connection points effect"); return; } let isMounted = true; const calculatePoints = async () => { var _a, _b; if (!isMounted) return; try { if (((_a = start === null || start === void 0 ? void 0 : start.element) === null || _a === void 0 ? void 0 : _a.current) && ((_b = end === null || end === void 0 ? void 0 : end.element) === null || _b === void 0 ? void 0 : _b.current)) { // Both are React elements - measure them and calculate connection points const points = await calculateConnectionPoints(start.element.current, end.element.current, startSocket, endSocket); if (points && isMounted) { setStartPoint(points.start); setEndPoint(points.end); } } else if ((start === null || start === void 0 ? void 0 : start.point) && (end === null || end === void 0 ? void 0 : end.point)) { // Both are fixed points - use them directly if (isMounted) { setStartPoint(start.point); setEndPoint(end.point); } } } catch (error) { console.warn("LeaderLine: Failed to calculate connection points:", error); } }; // Only calculate points if start and end are defined if (start && end) { calculatePoints(); } return () => { isMounted = false; }; }, [start, end, startSocket, endSocket]); /** * @description Prepare labels configuration for multi-label support * Memoized to prevent unnecessary recalculations */ const labels = useMemo(() => ({ startLabel, middleLabel, endLabel, captionLabel, pathLabel, }), [startLabel, middleLabel, endLabel, captionLabel, pathLabel]); /** * @description Generate label render data using the multi-label hook */ const { labelRenderData } = useMultipleLabels(startPoint, endPoint, labels); /** * @description Update SVG bounding box when points or styling changes * This ensures the SVG container is large enough to contain the entire line */ useEffect(() => { // Safety check for useEffect availability if (!useEffect) { console.error("LeaderLine: useEffect not available in SVG bounds effect"); return; } let isMounted = true; if (startPoint && endPoint && isMounted) { try { const pathType = typeof path === "string" ? path : (path === null || path === void 0 ? void 0 : path.type) || "straight"; const normalizedMainOutline = normalizeOutlineOptions(outline); const newBounds = calculatePathBoundingBoxWithOutline(startPoint, endPoint, pathType, curvature || 0.2, effectiveStrokeWidth || 2, normalizedMainOutline); // Add padding to prevent clipping const padding = 20; if (isMounted) { setSvgBounds({ x: newBounds.x - padding, y: newBounds.y - padding, width: newBounds.width + padding * 2, height: newBounds.height + padding * 2, }); } } catch (error) { console.warn("LeaderLine: Failed to calculate SVG bounds:", error); } } return () => { isMounted = false; }; }, [startPoint, endPoint, path, curvature, effectiveStrokeWidth, outline]); /** * @description Memoize connection points calculation with threshold */ const throttledConnectionPoints = useMemo(() => { if (!startPoint || !endPoint) return null; try { const now = Date.now(); if (optimizeUpdates && now - lastUpdateTime < updateThrottleDelay) { return { start: startPoint, end: endPoint }; } setLastUpdateTime(now); return { start: startPoint, end: endPoint }; } catch (error) { console.warn("LeaderLine: Error in connection points calculation:", error); return null; } }, [ startPoint, endPoint, optimizeUpdates, lastUpdateTime, updateThrottleDelay, ]); /** * @description Generate SVG path data for the line * Memoized for performance optimization */ const enhancedPathData = useMemo(() => { if (!throttledConnectionPoints) return ""; try { const { start, end } = throttledConnectionPoints; // Use enhanced bezier generation for smoother curves if (typeof path === "object" && (path === null || path === void 0 ? void 0 : path.type) === "fluid") { return generateSmoothBezierPath(start, end, curvature || 0.2, startSocket, endSocket); } return generateEnhancedPathData(start, end, path || "straight", curvature || 0.2); } catch (error) { console.warn("LeaderLine: Error generating path data:", error); return ""; } }, [throttledConnectionPoints, path, curvature, startSocket, endSocket]); /** * @description Normalize outline options with default values * Memoized to prevent object recreation on each render */ const normalizedMainOutline = useMemo(() => { return normalizeOutlineOptions(outline); }, [outline]); const normalizedStartPlugOutline = useMemo(() => { return normalizePlugOutlineOptions(startPlugOutline); }, [startPlugOutline]); const normalizedEndPlugOutline = useMemo(() => { return normalizePlugOutlineOptions(endPlugOutline); }, [endPlugOutline]); /** * @description Create SVG paths for start and end plugs/markers * Memoized for performance */ const startPlugPath = useMemo(() => { return createEnhancedPlugPath(startPlug, startPlugSize); }, [startPlug, startPlugSize]); const endPlugPath = useMemo(() => { return createEnhancedPlugPath(endPlug, endPlugSize); }, [endPlug, endPlugSize]); /** * @description Render drop shadow effect if enabled * @returns {React.ReactElement|null} Shadow path element or null */ const renderDropShadow = useCallback(() => { if (!dropShadow) return null; const shadowOptions = typeof dropShadow === "boolean" ? { dx: 2, dy: 2, blur: 2, color: "rgba(0,0,0,0.3)", opacity: 0.3 } : { dx: 2, dy: 2, blur: 2, color: "rgba(0,0,0,0.3)", opacity: 0.3, ...dropShadow, }; return (React.createElement(Path, { d: enhancedPathData, stroke: shadowOptions.color, strokeWidth: effectiveStrokeWidth, fill: "none", opacity: shadowOptions.opacity, transform: `translate(${shadowOptions.dx}, ${shadowOptions.dy})` })); }, [dropShadow, enhancedPathData, effectiveStrokeWidth]); /** * @description Render simple label (legacy support) * @returns {React.ReactElement|null} SVG text element or null */ const renderLabel = useCallback(() => { var _a, _b; if (!label || !startPoint || !endPoint) return null; const labelConfig = typeof label === "string" ? { text: label } : label; const midPoint = { x: (startPoint.x + endPoint.x) / 2, y: (startPoint.y + endPoint.y) / 2, }; return (React.createElement(SvgText, { x: midPoint.x + (((_a = labelConfig.offset) === null || _a === void 0 ? void 0 : _a.x) || 0), y: midPoint.y + (((_b = labelConfig.offset) === null || _b === void 0 ? void 0 : _b.y) || 0), fontSize: labelConfig.fontSize || 14, fontFamily: labelConfig.fontFamily || "Arial", fill: labelConfig.color || "#000000", textAnchor: "middle", alignmentBaseline: "middle" }, labelConfig.text)); }, [label, startPoint, endPoint]); /** * @description Render multiple enhanced labels as React Native Views * @returns {React.ReactElement[]} Array of label View components */ const renderEnhancedLabels = useCallback(() => { if (!throttledConnectionPoints) return []; return labelRenderData.map(({ key, config, position }) => { // Calculate optimal label position to avoid line overlap const adjustedPosition = { x: position.x, y: position.y - 20, // Better default offset }; const labelStyle = { position: "absolute", left: adjustedPosition.x - 50, top: adjustedPosition.y - 15, backgroundColor: config.backgroundColor || "rgba(255,255,255,0.9)", borderRadius: config.borderRadius || 6, padding: typeof config.padding === "number" ? config.padding : 8, minWidth: 30, alignItems: "center", justifyContent: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.22, shadowRadius: 2.22, elevation: 3, }; return (React.createElement(View, { key: key, style: labelStyle }, React.createElement(Text, { style: { fontSize: config.fontSize || 14, fontFamily: config.fontFamily || "System", color: config.color || "#333", textAlign: "center", fontWeight: "500", } }, config.text))); }); }, [throttledConnectionPoints, labelRenderData]); // Early return if no connection points are available if (!throttledConnectionPoints) { return (React.createElement(View, { style: [{ position: "absolute" }, style], testID: testID }, children)); } return (React.createElement(View, { style: [{ position: "absolute" }, style], testID: testID }, React.createElement(Svg, { width: svgBounds.width, height: svgBounds.height, style: { position: "absolute", left: svgBounds.x, top: svgBounds.y, }, testID: "svg", accessibilityLabel: "Leader line connection", accessibilityRole: "image", accessibilityHint: "Visual connection between UI elements" }, React.createElement(Defs, null, startPlug !== "none" && startPlug !== "behind" && (React.createElement(Marker, { id: "start-marker", markerWidth: startPlugSize, markerHeight: startPlugSize, refX: 0, refY: startPlugSize / 2, orient: "auto" }, normalizedStartPlugOutline && (React.createElement(Path, { d: startPlugPath, fill: normalizedStartPlugOutline.color === "auto" ? startPlugColor || color : normalizedStartPlugOutline.color, opacity: normalizedStartPlugOutline.opacity || 1, transform: `scale(${1 + (normalizedStartPlugOutline.width || 1) * 0.1})` })), React.createElement(Path, { d: startPlugPath, fill: startPlugColor || color }))), endPlug !== "none" && endPlug !== "behind" && (React.createElement(Marker, { id: "end-marker", markerWidth: endPlugSize, markerHeight: endPlugSize, refX: endPlugSize, refY: endPlugSize / 2, orient: "auto" }, normalizedEndPlugOutline && (React.createElement(Path, { d: endPlugPath, fill: normalizedEndPlugOutline.color === "auto" ? endPlugColor || color : normalizedEndPlugOutline.color, opacity: normalizedEndPlugOutline.opacity || 1, transform: `scale(${1 + (normalizedEndPlugOutline.width || 1) * 0.1})` })), React.createElement(Path, { d: endPlugPath, fill: endPlugColor || color })))), renderDropShadow(), normalizedMainOutline && (React.createElement(Path, { d: enhancedPathData, stroke: normalizedMainOutline.color === "auto" ? color : normalizedMainOutline.color || color, strokeWidth: effectiveStrokeWidth + (normalizedMainOutline.width || 1) * 2, fill: "none", opacity: normalizedMainOutline.opacity || 1, strokeDasharray: dash ? generateDashArray(dash) : undefined })), React.createElement(Path, { d: enhancedPathData, stroke: color, strokeWidth: effectiveStrokeWidth, fill: "none", opacity: opacity, strokeDasharray: dash ? generateDashArray(dash) : undefined, strokeLinecap: "round", strokeLinejoin: "round", markerStart: startPlug !== "none" && startPlug !== "behind" ? "url(#start-marker)" : undefined, markerEnd: endPlug !== "none" && endPlug !== "behind" ? "url(#end-marker)" : undefined, testID: "path" }), startPlug === "behind" && startPoint && (React.createElement(Path, { d: createEnhancedPlugPath("square", startPlugSize), fill: startPlugColor || color, transform: `translate(${startPoint.x}, ${startPoint.y - startPlugSize / 2})` })), endPlug === "behind" && endPoint && (React.createElement(Path, { d: createEnhancedPlugPath("square", endPlugSize), fill: endPlugColor || color, transform: `translate(${endPoint.x - endPlugSize}, ${endPoint.y - endPlugSize / 2})` })), renderLabel()), renderEnhancedLabels(), children)); }); LeaderLine.displayName = "LeaderLine"; /** * @namespace LeaderLineEnhanced * @description Enhanced exports with anchor creation functions for compatibility with original API * * @example Using anchor functions * ```tsx * import { LeaderLineEnhanced } from 'react-native-leader-line'; * * const pointAnchor = LeaderLineEnhanced.pointAnchor(elementRef, 10, 20); * const areaAnchor = LeaderLineEnhanced.areaAnchor(elementRef, 0, 0, 100, 50); * ``` */ export const LeaderLineEnhanced = { pointAnchor, areaAnchor, mouseHoverAnchor, LeaderLine, }; export default LeaderLine; //# sourceMappingURL=LeaderLine.js.map