如何在只知道它的索引的情况下获取段落中单词的边界矩形?

How can I get a word's bounding rectangle within a paragraph, while only knowing it's index?

我正在 React 应用程序中创建一个文本转语音功能,该功能可以通过在其后面放置背景来突出显示当前说出的词。

该功能与 Firefox reader view 非常相似。

我实施的解决方案只是剪切段落字符串并在每次渲染时在口语周围放置一个跨度,使其占用大量资源并且无法制作动画。

这是代码:(我打算废弃)

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
                )
            )}
        </>
    );
}

Firefox reader 正在使用更智能的方式来执行此操作。它使用 div 放在口语后面,然后是 moved around :

包含高亮效果的div直接使用绝对坐标放置

他们如何在只知道字符串索引的情况下访问段落中单词的边界矩形?

Here is the result of the following solution


编辑 2:

如评论中所述,固定定位会导致屏幕尺寸改变以及用户缩放或滚动时出现问题。

要创建相对定位,可以先获取父元素的偏移量:const { offsetTop, offsetLeft } = containerEl.current;

然后将它们减去获取的 DomRect :

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

只需将 position: relative 应用于文本父级,然后将 position: absolute 应用于文本叠加层,瞧。


编辑:

下面的解决方案不适用于 wrapped word(例如下图中的 non-violent

结果框占据了一个矩形,覆盖了单词的两个部分。

相反,使用 getClientRects 获取呈现相同字符串的所有框,然后将其映射到相同的叠加层:

状态类型:const [highlighst, setHighlights] = useState<DOMRect[] | null>(null);

在高亮设置中:return Array.from(range.getBoundingClientRect());

效果图:

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

结果:


我最终能够使用 Range API 做到这一点。

setStartsetEnd 方法可以接受索引变量作为第二个参数。

然后我在范围本身上使用 getBoundingClientRect 获取文本坐标,并将其放入我的状态。

我现在可以在渲染中的固定 div 上应用这些值:

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>
    );
}

CSS为移动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;
}

如果有人感兴趣,我会上传解决方案的视频