import React, { useMemo, useRef, useLayoutEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import { Platform, StyleSheet, Dimensions, LayoutAnimation, Animated } from 'react-native'; import { useAutoheight, HandleHTMLDimensionsFeature, HandleLinkPressFeature, MinimalWebViewProps, LinkPressTarget, useWebshell } from '@formidable-webview/webshell'; import { linkPressTargetToOnDOMLinkPressArgs } from '@native-html/plugins-core'; import { cssRulesFromSpecs, defaultTableStylesSpecs } from './css-rules'; import { TableStyleSpecs, HTMLTableProps, TableContentHeightState, HTMLTableStats } from './types'; const styles = StyleSheet.create({ container: { flex: 1, // See https://github.com/react-native-community/react-native-webview/issues/101 overflow: 'hidden' } }); const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 }; const DEFAULT_TRANSITION_DURATION = 120; function animateNextFrames(duration?: number) { LayoutAnimation.configureNext({ duration: duration || DEFAULT_TRANSITION_DURATION, update: { type: LayoutAnimation.Types.easeInEaseOut } }); } const tableStylePropTypeSpec: Record = { linkColor: PropTypes.string, fontFamily: PropTypes.string, fontSizePx: PropTypes.number, tdBorderColor: PropTypes.string, thBorderColor: PropTypes.string, trOddBackground: PropTypes.string, trOddColor: PropTypes.string, trEvenBackground: PropTypes.string, trEvenColor: PropTypes.string, rowsBorderWidthPx: PropTypes.number, columnsBorderWidthPx: PropTypes.number, outerBorderColor: PropTypes.string, outerBorderWidthPx: PropTypes.number, cellPaddingEm: PropTypes.number, fitContainerWidth: PropTypes.bool, fitContainerHeight: PropTypes.bool, selectableText: PropTypes.bool, thEvenBackground: PropTypes.string, thEvenColor: PropTypes.string, thOddBackground: PropTypes.string, thOddColor: PropTypes.string }; function findHeight({ computeContainerHeight, computeHeuristicContentHeight, contentHeight, ...stats }: { computeContainerHeight: (state: TableContentHeightState) => number | null; computeHeuristicContentHeight: (tableStats: HTMLTableStats) => number; contentHeight: number | null; } & HTMLTableStats) { if (typeof contentHeight === 'number') { return computeContainerHeight({ type: 'accurate', contentHeight: contentHeight }); } return computeContainerHeight({ type: 'heuristic', contentHeight: computeHeuristicContentHeight(stats) }); } function defaultComputeHeuristicContentHeight(tableStats: HTMLTableStats) { const { numOfChars, numOfRows } = tableStats; const width = Dimensions.get('window').width; const charsPerLine = (30 * width) / 400; const lineHeight = 20; const approxNumOfLines = Math.floor(numOfChars / charsPerLine); return Math.max(approxNumOfLines, numOfRows) * lineHeight; } function defaultComputeContainerHeight(state: TableContentHeightState) { return state.contentHeight; } function useAnimatedAutoheight({ computeContainerHeight, computeHeuristicContentHeight, animationType, animationDuration, webViewProps, numOfChars, numOfColumns, numOfRows }: Pick< HTMLTableProps, | 'animationType' | 'animationDuration' | 'numOfChars' | 'numOfColumns' | 'numOfRows' > & { webViewProps: any } & Required< Pick< HTMLTableProps, 'computeContainerHeight' | 'computeHeuristicContentHeight' > >) { const animatedHeight = useRef(new Animated.Value(0)).current; const { autoheightWebshellProps, contentSize, syncState } = useAutoheight({ webshellProps: webViewProps as any, resetHeightOnViewportWidthChange: false }); const containerHeight = useMemo( () => findHeight({ computeContainerHeight, computeHeuristicContentHeight, contentHeight: syncState === 'synced' ? contentSize.height || 0 : null, numOfChars, numOfColumns, numOfRows }), [ computeContainerHeight, computeHeuristicContentHeight, contentSize.height, syncState, numOfChars, numOfColumns, numOfRows ] ); const oldContainerHeightRef = useRef(containerHeight); const containerStyle: { height: any } = useMemo( () => animationType === 'animated' && containerHeight !== null ? { height: animatedHeight.interpolate({ inputRange: [0, 1], outputRange: [ oldContainerHeightRef.current || containerHeight, containerHeight ] }) } : { height: !containerHeight || Number.isNaN(containerHeight) ? undefined : containerHeight }, [animatedHeight, animationType, containerHeight] ); useLayoutEffect(() => { if (oldContainerHeightRef.current !== containerHeight) { oldContainerHeightRef.current = containerHeight; if (animationType === 'animated') { Animated.timing(animatedHeight, { toValue: 1, duration: animationDuration, useNativeDriver: false }).start(); } else if (animationType === 'layout') { animateNextFrames(animationDuration); } } }, [containerHeight, animationDuration, animationType, animatedHeight]); return { containerStyle, autoheightWebshellProps }; } function useSource({ tableStyleSpecs, cssRules, sourceBaseUrl, html, maxScale }: Pick< HTMLTableProps, 'html' | 'cssRules' | 'tableStyleSpecs' | 'sourceBaseUrl' | 'maxScale' >) { const injectedHtml = useMemo(() => { const styleSpecs = tableStyleSpecs ? { ...defaultTableStylesSpecs, ...tableStyleSpecs } : defaultTableStylesSpecs; const tableCssStyle = typeof cssRules === 'string' ? cssRules : cssRulesFromSpecs(styleSpecs); return ` Table ${html} `; }, [tableStyleSpecs, cssRules, html, maxScale]); return useMemo( () => ({ html: injectedHtml, baseUrl: sourceBaseUrl ?? undefined }), [injectedHtml, sourceBaseUrl] ); } const features = [ new HandleLinkPressFeature(), new HandleHTMLDimensionsFeature() ]; /** * A component capable of rendering a html string which root tag is a table * tag. This component should not be used directly, except with custom * renderers. * * @public */ export const HTMLTable = function HTMLTable({ WebView, tableStyleSpecs, cssRules, html, sourceBaseUrl, animationType, computeHeuristicContentHeight = defaultComputeHeuristicContentHeight, computeContainerHeight = defaultComputeContainerHeight, webViewProps: userWebViewProps, style, onLinkPress, animationDuration, htmlAttribs, maxScale, ...stats }: HTMLTableProps) { const onDOMLinkPress = useCallback( (event: LinkPressTarget) => onLinkPress?.apply(null, linkPressTargetToOnDOMLinkPressArgs(event)), [onLinkPress] ); const { autoheightWebshellProps, containerStyle } = useAnimatedAutoheight({ ...stats, animationDuration, animationType, computeContainerHeight, computeHeuristicContentHeight, webViewProps: { scalesPageToFit: Platform.select({ android: false, ios: undefined }), automaticallyAdjustContentInsets: false, scrollEnabled: true, contentInset: defaultInsets, ...userWebViewProps, onDOMLinkPress, source: useSource({ html, cssRules, sourceBaseUrl, tableStyleSpecs }), webshellDebug: false } }); const webViewProps = useWebshell({ features, props: autoheightWebshellProps }); return ( {React.createElement(WebView, webViewProps)} ); }; const propTypes: Record = { animationDuration: PropTypes.number.isRequired, animationType: PropTypes.oneOf(['none', 'animated', 'layout']), computeContainerHeight: PropTypes.func.isRequired, computeHeuristicContentHeight: PropTypes.func.isRequired, html: PropTypes.string.isRequired, numOfChars: PropTypes.number.isRequired, numOfColumns: PropTypes.number.isRequired, numOfRows: PropTypes.number.isRequired, WebView: PropTypes.elementType.isRequired, onLinkPress: PropTypes.func, style: PropTypes.any, tableStyleSpecs: PropTypes.shape(tableStylePropTypeSpec), cssRules: PropTypes.string, webViewProps: PropTypes.object, sourceBaseUrl: PropTypes.string, maxScale: PropTypes.number.isRequired, htmlAttribs: PropTypes.any, displayMode: PropTypes.oneOf(['flex', 'expand']) }; const defaultProps = { animationDuration: DEFAULT_TRANSITION_DURATION, animationType: 'animated', computeHeuristicContentHeight: defaultComputeHeuristicContentHeight, computeContainerHeight: defaultComputeContainerHeight, maxScale: 1 }; Object.defineProperty(HTMLTable, 'propTypes', { value: propTypes }); Object.defineProperty(HTMLTable, 'defaultProps', { value: defaultProps });