@neo4j-ndl/react
Version:
React implementation of Neo4j Design System
205 lines • 8.92 kB
JavaScript
;
/**
*
* 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/>.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.useTruncateWithButton = useTruncateWithButton;
const base_1 = require("@neo4j-ndl/base");
const react_1 = require("react");
const hooks_1 = require("../hooks");
const text_overflow_utils_1 = require("./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' ? base_1.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 = (0, text_overflow_utils_1.truncateReactNodeByCharacters)(content, mid, buttonType !== 'ellipsis').truncatedContent;
low = mid + 1;
}
else {
high = mid - 1;
}
}
return bestFitContent;
};
function useTruncateWithButton({ content, lineClamp, button, onExpandedToggle, maxCharacters, buttonType, closeTooltip, isDisabled, }) {
const containerRef = (0, react_1.useRef)(null);
const measureRef = (0, react_1.useRef)(null);
const [isExpanded, setIsExpanded] = (0, react_1.useState)(false);
const [isTruncated, setIsTruncated] = (0, react_1.useState)(false);
const [truncatedContent, setTruncatedContent] = (0, react_1.useState)(content);
// Memoize text content to avoid repeated traversals
const textContent = (0, react_1.useMemo)(() => (0, text_overflow_utils_1.getTextContent)(content), [content]);
const toggleExpand = (0, react_1.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 = (0, react_1.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 = (0, text_overflow_utils_1.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 = (0, hooks_1.useDebounceCallback)(recalculateTruncation, 100);
// Initial calculation and resize handling
(0, react_1.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