HBB's Blog

Ordinary road, record every bit

鼠标悬浮翻译的实现原理

去年帮助沉浸式翻译打造了鼠标悬浮翻译功能,做一些简单的记录。

根据坐标获取片段

核心是需要找到某个位置的DOM到底是什么,这里会用到2个API。

export function getRangeFromPoint(x: number, y: number) {
// @ts-ignore: it's ok
if (document.caretPositionFromPoint) {
// @ts-ignore: it's ok
const position = document.caretPositionFromPoint(x, y);
if (position) {
const range = document.createRange();
const offsetNode = position.offsetNode;

try {
range.setStart(offsetNode, position.offset);
range.setEnd(offsetNode, position.offset);
} catch (e) {
console.warn("getRangeFromPoint error", e);
return null;
}
return range;
}
return null;
} else if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(x, y);
return range;
} else {
return null;
}
}

获取文本

找到了具体位置的Range之后就需要想办法识别DOM并提取你想要的信息。

function getMouseOverParagraph(
clientX: number,
clientY: number,
) {
const range = getRangeFromPoint(clientX, clientY);
if (range == null) return;

/**
* Check whether the target element is inside the shadow element
* @returns
*/
const checkTheUnTextElement = () => {
const pointElement = document.elementFromPoint(
clientX,
clientY,
) as HTMLElement;

if (!pointElement) return;

const realInnerElement = findElementInShadow(
pointElement,
clientX,
clientY,
);
const notFoundInShadow = realInnerElement === pointElement;
if (notFoundInShadow) {
const isIgnoreElements = (pointElement.nodeName === "IFRAME") || isShadowElement(pointElement);
// Ignore elements is shadow or iframe
return isIgnoreElements ? undefined : pointElement;
} else {
return realInnerElement;
}
};

/**
* Check text element
* @returns
*/
const checkTheTextNode = () => {
//expand char to get word,sentence based on setting
//except if mouse target is special web block which need to be handled as block for clarity, handle as block
try {
range.setStartBefore(range.startContainer);
range.setEndAfter(range.startContainer);
} catch (error) {
log.debug("get mouse over word fail", error);
}

//check mouse is actually in text bound rect
const rect = range.getBoundingClientRect(); //mouse in word rect
if (
rect.left > clientX ||
rect.right < clientX ||
rect.top > clientY ||
rect.bottom < clientY
) {
return;
}

return range.startContainer as HTMLElement
};

let findedElement;
if (range.startContainer.nodeType !== Node.TEXT_NODE) {
findedElement = checkTheUnTextElement();
} else {
findedElement = checkTheTextNode();
}

return findedElement;
}

判断是否为Shadow Element

shadow element 是需要特殊处理的

export function isShadowElement(element: HTMLElement) {
// check element is shadow root
// @ts-ignore: it's ok
if (element.host && element.mode) {
return true;
} else {
return false;
}
}

考虑ShadowDOM

部分网站会嵌套CustomElement渲染,所以无法一下子获取TEXT,需要用一些小技巧发现最底层的文本。

elementFromPoint()的作用就是从 Element 中找出某个坐标处最顶层的Element,如果找不到就返回null。

利用它的这个能力,可以递归从 Shadow DOM 嵌套中获取最底层的内容。

export function findElementInShadow(
ele: HTMLElement,
x: number,
y: number,
): HTMLElement {
/** avoid overflow threshold value, max 100. */
let findCount = 0;
const finder = (
ele: HTMLElement,
x: number,
y: number,
preShadow?: HTMLElement,
): HTMLElement => {
// avoid overflow
if (++findCount > 100) return ele;
// avoid allways get same element
if (preShadow === ele) return ele;
// @ts-ignore it's ok
const innerShadowDom = ele.shadowRoot;
if (!innerShadowDom) return ele; // 判断是否是叶子元素
// @ts-ignore it's ok
if (typeof innerShadowDom.elementFromPoint !== "function") return ele;

const innerDom = innerShadowDom.elementFromPoint(x, y) as HTMLElement;

return innerDom ? finder(innerDom, x, y, ele) : ele;
};

return finder(ele, x, y);
}

获取鼠标位置

最后通过监听 mousemove 的方式来获取鼠标的实时位置。

document.addEventListener('mousemove', (e: MouseEvent) => {
const selectioPparagraph = getMouseOverParagraph(e.clientX, e.clientY);
})