2

I am creating a text-to-speech feature in a react app that would be able to highlight the currently spoken word by putting a background behind it.

The feature is very similar to the Firefox reader view.

The solution that I implemented just cuts the paragraph string and puts a span around the spoken word at each rendering, making it heavy on resources and impossible to animate.

Here is the code : (Which I intend to scrap)

export interface SpeakEvent {
    start: number;
    end: number;
    type: string;
}

export default function TextNode({ content }: TextNodeProps) {
    const [highlight, setHighlight] = useState<SpeakEvent | null>(null);

    useEffect(() => {
        registerText((ev) => {
            if (ev?.type === 'word' || !ev)
                setHighlight((old) => {
                    /* Irrelevant code */
                    return ev;
                });
        }, content);
    }, [content]);

    const { start, end } = highlight ?? {};

    let segments = [content];

    if (highlight) {
        segments = [
            segments[0].slice(0, start),
            segments[0].slice(start, end),
            segments[0].slice(end),
        ];
    }

    return (
        <>
            {segments.map((seg, i) =>
                i === 1 ? (
                    <span key={i} className={'highlight'}>
                        {seg}
                    </span>
                ) : (
                    seg
                )
            )}
        </>
    );
}

The Firefox reader is using a smarter way to do this. It uses a div placed behind the spoken word which is then moved around :

Single word highlight

The div containing the highlighting effect is directly placed using absolute coordinates.

How can they access a word's bounding rectangle within a paragraph, while only knowing the string's index ?

Treycos
  • 7,373
  • 3
  • 24
  • 47
  • Does this answer your question? [Calculate text width with JavaScript](https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript) – GalAbra Apr 18 '20 at 14:22
  • This solution doesn't seem work with multi-lines paragraphs – Treycos Apr 18 '20 at 14:31

1 Answers1

3

Here is the result of the following solution


EDIT 2:

As mentioned in the comments, having a fixed positioning will lead to problem when the screen changes sizes and when the user zooms or scrolls.

To create a relative positioning, it is possible to first fetch the offsets of the parent element : const { offsetTop, offsetLeft } = containerEl.current;

And then subtract them to the fetch DomRect :

return Array.from(range.getClientRects()).map(
    ({ top, left, width, height }) => ({
        top: top - offsetTop,
        left: left - offsetLeft,
        width,
        height,
    })
);

Just apply position: relative to the text parent and then position: absolute to the text overlay and voila.


EDIT:

The solution below will not work with wrapped word (eg. non-violent in the picture below)

enter image description here

The resulting box takes up a rectangle covering both parts of the word.

Instead, use getClientRects to get all boxes where the same string is rendered, then map it to the same overlay :

State type: const [highlighst, setHighlights] = useState<DOMRect[] | null>(null);

In the highlight setting: return Array.from(range.getBoundingClientRect());

The rendering:

{highlights &&
    highlights.map(({ top, left, width, height }) => (
        <span
            className='text-highlight'
            style={{
                top,
                left,
                width,
                height,
            }}
        ></span>
    ))}

Result :

enter image description here


I ended up being able to do just that using the Range API.

The setStart and setEnd methods can accept an index variable as a second parameter.

I then get the text coordss using getBoundingClientRect on the range itself and put it within my state.

I can now apply these values on a fixed div in my render :

const range = document.createRange();

export default function TextNode({ content, footnote }: TextNodeProps) {
    const [highlight, setHighlight] = useState<DOMRect | null>(null);
    const containerEl = useRef<HTMLSpanElement>(null);

    useEffect(() => {
        registerText((ev) => {
            if (!ev) {
                setHighlight(null);
                return;
            }

            if (ev.type === 'sentence') {
                (textEl.current as HTMLSpanElement | null)?.scrollIntoView(
                    scrollOptions
                );
            }

            if (ev.type === 'word')
                setHighlight((old) => {
                    const txtNode = containerEl.current?.firstChild as Node;

                    range.setStart(txtNode, ev.start);
                    range.setEnd(txtNode, ev.end);

                    if (!old) {
                        (containerEl.current as HTMLSpanElement | null)?.scrollIntoView(
                            scrollOptions
                        );
                    }

                    return range.getBoundingClientRect();
                });
        }, content);
    }, [content]);

    return (
        <span ref={containerEl}>
            {content}
            {highlight && (
                <div
                    className='text-highlight'
                    style={{
                        top: highlight.top,
                        left: highlight.left,
                        width: highlight.width,
                        height: highlight.height,
                    }}
                ></div>
            )}
        </span>
    );
}

The CSS for the moving div :

.text-highlight {
    position: fixed;
    border-bottom: 4px solid blue;
    opacity: 0.7;
    transition-property: top, left, height, width;
    transition-duration: 0.2s;
    transform-style: ease-in-out;
}

If anyone is interested I'll upload a video of the solution working

Treycos
  • 7,373
  • 3
  • 24
  • 47
  • 1
    That looks awesome. I'll be happy to see it working! Consider using `position: absolute` inside an element, which is given `position: relative`, since `position: fixed`, might bring you errors when scrolling, zooming the page in/out and etc. – Enchew Apr 18 '20 at 16:14
  • Thank you ! Alright, I'll record and upload a very early version. Thanks for the advice, I'll try changing it up – Treycos Apr 18 '20 at 16:34
  • I added a short video link in my answer – Treycos Apr 18 '20 at 16:46
  • 1
    It looks awesome. – Enchew Apr 18 '20 at 17:01
  • @Treycos would you mind sharing the javascript you used to get the text coords for each word? – Marc May 06 '22 at 17:18
  • IIRC `getBoundingClientRect` is used, everything is linked above – Treycos May 07 '22 at 10:17