UNPKG

@neo4j-ndl/react

Version:

React implementation of Neo4j Design System

202 lines 8.66 kB
/** * * Copyright (c) "Neo4j" * Neo4j Sweden AB [http://neo4j.com] * * This file is part of Neo4j. * * Neo4j is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ import { tokens } from '@neo4j-ndl/base'; import { useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { useDebounceCallback } from '../hooks'; import { getTextContent, truncateReactNodeByCharacters, } from './text-overflow-utils'; // Cache for computed styles to avoid repeated getComputedStyle calls const styleCache = new WeakMap(); // Helper function to copy relevant styles for measurement const copyStylesForMeasurement = (measureElement, sourceElement) => { // Use cached styles if available let sourceStyles = styleCache.get(sourceElement); if (sourceStyles === undefined) { sourceStyles = window.getComputedStyle(sourceElement); styleCache.set(sourceElement, sourceStyles); } measureElement.style.position = 'absolute'; measureElement.style.visibility = 'hidden'; measureElement.style.top = '-9999px'; measureElement.style.left = '-9999px'; measureElement.style.width = sourceStyles.width; measureElement.style.fontFamily = sourceStyles.fontFamily; measureElement.style.fontSize = sourceStyles.fontSize; measureElement.style.fontWeight = sourceStyles.fontWeight; measureElement.style.lineHeight = sourceStyles.lineHeight; measureElement.style.letterSpacing = sourceStyles.letterSpacing; measureElement.style.wordSpacing = sourceStyles.wordSpacing; measureElement.style.overflow = 'hidden'; measureElement.style.wordBreak = 'break-word'; }; // Helper function to find the optimal truncation point using optimized binary search const findTruncationPoint = (content, allText, measureElement, maxHeight, button, buttonType) => { let low = 0; let high = allText.length; let bestFitContent = null; // Pre-calculate button text to avoid repeated string operations const buttonText = typeof button === 'string' ? button : 'View more'; const buttonMargin = buttonType !== 'ellipsis' ? tokens.space[4] : '0'; // Create reusable DOM elements to avoid repeated creation const tempSpan = document.createElement('span'); const btnSpan = document.createElement('span'); btnSpan.style.display = 'inline-block'; btnSpan.style.marginLeft = buttonMargin; while (low <= high) { const mid = Math.floor((low + high) / 2); const testText = allText.slice(0, mid); measureElement.innerHTML = ''; // Reuse span elements tempSpan.textContent = buttonType === 'ellipsis' ? testText : `${testText}\u2026`; measureElement.appendChild(tempSpan); btnSpan.textContent = buttonText; measureElement.appendChild(btnSpan); if (measureElement.scrollHeight <= maxHeight) { bestFitContent = truncateReactNodeByCharacters(content, mid, buttonType !== 'ellipsis').truncatedContent; low = mid + 1; } else { high = mid - 1; } } return bestFitContent; }; export function useTruncateWithButton({ content, lineClamp, button, onExpandedToggle, maxCharacters, buttonType, closeTooltip, isDisabled, }) { const containerRef = useRef(null); const measureRef = useRef(null); const [isExpanded, setIsExpanded] = useState(false); const [isTruncated, setIsTruncated] = useState(false); const [truncatedContent, setTruncatedContent] = useState(content); // Memoize text content to avoid repeated traversals const textContent = useMemo(() => getTextContent(content), [content]); const toggleExpand = useCallback(() => { if (isDisabled === true) { return; } const shouldExpand = !isExpanded; setIsExpanded(shouldExpand); if (shouldExpand) { onExpandedToggle === null || onExpandedToggle === void 0 ? void 0 : onExpandedToggle(true); closeTooltip === null || closeTooltip === void 0 ? void 0 : closeTooltip(); } else { onExpandedToggle === null || onExpandedToggle === void 0 ? void 0 : onExpandedToggle(false); } }, [isExpanded, onExpandedToggle, closeTooltip, isDisabled]); const recalculateTruncation = useCallback(() => { // If disabled, don't perform any calculations if (isDisabled === true) { setIsTruncated(false); setTruncatedContent(content); return; } // Character-based truncation takes precedence if (typeof maxCharacters === 'number') { const result = truncateReactNodeByCharacters(content, maxCharacters); setIsTruncated(result.needsTruncation); setTruncatedContent(result.needsTruncation ? result.truncatedContent : content); return; } // Fallback to line-based truncation if (!measureRef.current || !containerRef.current) { return; } const measure = measureRef.current; const container = containerRef.current; copyStylesForMeasurement(measure, container); const containerStyles = window.getComputedStyle(container); const lineHeight = parseFloat(containerStyles.lineHeight); const maxHeight = lineHeight * lineClamp; const allText = textContent; // Early exit if no text content if (allText.length === 0) { setIsTruncated(false); setTruncatedContent(content); return; } // First, check if truncation is needed at all measure.innerHTML = ''; const fullTextSpan = document.createElement('span'); fullTextSpan.textContent = allText; measure.appendChild(fullTextSpan); const isTruncationNeeded = measure.scrollHeight > maxHeight; if (!isTruncationNeeded) { setIsTruncated(false); setTruncatedContent(content); return; } // Binary search for truncation point const bestFitContent = findTruncationPoint(content, allText, measure, maxHeight, button, buttonType); setIsTruncated(true); setTruncatedContent(bestFitContent); }, [ content, button, lineClamp, maxCharacters, buttonType, isDisabled, textContent, ]); // Create debounced version of recalculateTruncation for resize events const debouncedRecalculateTruncation = useDebounceCallback(recalculateTruncation, 100); // Initial calculation and resize handling useLayoutEffect(() => { // If disabled, don't set up any observers or event listeners if (isDisabled === true) { return; } recalculateTruncation(); // Set up resize observer for container size changes const container = containerRef.current; if (!container) { return; } const resizeObserver = new ResizeObserver(() => { debouncedRecalculateTruncation(); }); resizeObserver.observe(container); // Also observe parent element to catch container size changes if (container.parentElement) { resizeObserver.observe(container.parentElement); } // Handle window resize events const handleWindowResize = () => { debouncedRecalculateTruncation(); }; window.addEventListener('resize', handleWindowResize); return () => { resizeObserver.disconnect(); window.removeEventListener('resize', handleWindowResize); }; }, [recalculateTruncation, debouncedRecalculateTruncation, isDisabled]); // Compute the final content to render based on expanded state const contentToRender = isExpanded ? content : truncatedContent; return { containerRef, contentToRender, isExpanded, isTruncated, measureRef, toggleExpand, }; } //# sourceMappingURL=use-truncate-with-button.js.map