react-native-leader-line
Version:
React Native port of leader-line library for drawing arrow lines and connectors
560 lines • 26.1 kB
JavaScript
/**
* @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