React Textbox with text alignment justified and ResizeObserver API

Although there is a CSS property text-align: justify to generate justified text inside a paragraph, the display of individual lines or text components is not possible with this approach. A React component can close the gap.

Tuedo#005

 by  tuedodev

15
Mar 2021
 0

The app that implements this functionality generates a multi-line text box inside an enclosing wrapper class with the blocktext component, whose individual line texts were previously specified in an array (messages).

Github Repository des Codes

CodePen-Demo

In interaction with browser's ResizeObserverAPI, the font size of the individual line is leveled up and down until the line width is within a specified tolerance range (precisionFrom and precisionTo) in relation to the wrapping text box. If the width of the line does not match the width of the text box exactly, the overhanging or missing pixels will be allocated to the individual letters of the line using the CSS property letter-spacing. In most cases a pixel-proof result will be achieved.

Inside the Blocktext function component that renders the single line of the text block, the resizeObsever for the text box is activated in the useEffect hook. This registration of the ResizeObserver takes place only after the first rendering of the component,because an empty array [] is passed in as second parameter.

As soon as the wrapping text box resizes, the callback function stored using useRef is called. If the counter variable of the state is 0 (as a sign that there is no approximation rendering to the line width at that moment), the line is re-rendered by resetting the state to the initial value. Since the state variable changes, the useLayoutEffect hook gets started.

Why is useLayoutEffect used instead of useEffect at this point? Because with the approximate rendering to the line width of the text container, the DOM is directly manipulated and in such useEffect> cases the component would be immediately re-rendered, which can cause some flicker effects and should be avoided. This useLayoutEffect hook is called whenever the state (in our case mainly the font size) and the fontsLoaded state variable changes.

The fontsLoaded boolean variable indicates whether all fonts specified in the font-face have been loaded. This value is set to true inside the useLayout hook as soon as the document.fonts.ready promise is resolved. The reason behind this approach is to avoid an approximate rendering as long as the fonts of the website have not been loaded yet. This would lead to undesirable results for obvious reasons, since each font has individual size dimensions.

if (fontsLoaded && !state.finished){
    [...]
}

Only if fontsLoaded is true, the approximation rendering within the useLayoutEffect hook takes place at all.

const textItemInitValues = {
	text: props.text,
        counter: 0,
        currentFontSize: 1,
        uom: 'rem', 
        letterSpacing: 'normal',
        finished: false
}

[...]

const [state, setState] = useReducer(
	(state, newState) => ({...state, ...newState}), textItemInitValues)

Within the variable state the current state of the line in a state object is stored after the rendering, the update of the value is done by a reducer to avoid an overwriting by accident of an object value at setState. This procedure is always recommended when updating more complex objects. The textInitValues constant saves the initial values of the object, which are used for the first rendering and for each resize of the text box.

const getTextItemDimensions = () => {
        let [childWidth, parentWidth, diff, sign, precisionValue ] = [null];
        if (textItem.current){
            childWidth = textItem.current.querySelector(`span`).getBoundingClientRect().width;
            parentWidth = textItem.current.parentElement.getBoundingClientRect().width
            if (childWidth && parentWidth){
                diff = parentWidth - childWidth;
                sign = diff >= 0 ? 1 : -1;
                precisionValue = childWidth / parentWidth;
            }
        }
        return { diff, sign, precisionValue };
    };
    
    // better use useLayoutEffect instead of useEffect as this hook function is changing and detecting the DOM
    useLayoutEffect(() => {

        const approximateFontSize = ({diff, sign}) => {
            let accumulator = 0;
            let summe = CALCULATION_MAPPING.current.map(step => {
                let subtotal = Math.abs(diff) - accumulator
                let result = Math.floor(subtotal / step.divider);
                accumulator += result * step.divider;
                return result * step.multiplier;
            }).reduce((a,b) => a + b);
            
            return summe * sign;
        }
        
        if (fontsLoaded && !state.finished){
            let {diff, sign, precisionValue} = getTextItemDimensions();
            if (diff !== null){
                if (precisionValue < CONFIG.current.precisionFrom || precisionValue > CONFIG.current.precisionTo ){
                    let adjustFontSize = approximateFontSize({diff, sign});
                    setState({currentFontSize: state.currentFontSize + adjustFontSize});
                    setState({counter: state.counter + 1})
                    if (state.counter > CONFIG.current.maximalCount){
                        setState({ finished: true })
                    }
                } else {
                    let letterSpacing = `${diff / state.text.length}px`;
                    setState({ finished: true, letterSpacing })
                }
            } 
        }
    }, [state, fontsLoaded])

The core functionality of the approximation rendering is implemented within the useLayoutEffect hook. From the getTextItemDimensions() function, the current widths of the line element and its parent, the textbox, are obtained using the getBoundingClientRect() element function.

The ratio of the widths of child and parent gives us a precisionValue. If this very ratio is not within a tolerance range (in this case the values 0.97 and 1.03 stored in the precisionFrom and precisionTo constants) a new value for the font size is determined in the approximateFontSize() function. The values stored in the helper constant CALCULATION_MAPPING determine that the font size is increased or decreased the more the remaining gap between line and text box width is.

This approximation to the width of the text box is repeated until the width of the line is within the tolerance range described above. A counter state prevents the function from starting an infinite loop. If the precision condition is met, i.e. the line inside the text box is close to the final result, the state variable finished is set to true and inside the else block the letter-spacing is set in such a way that the pixels out of line are distributed to the individual letters of the row in order to get an exact result.

This post has not been commented on yet.

Add a comment