Exclude documentElement from zoom calculation (#2227)

* Exclude documentElement from zoom calculation

* Add an option

* Refactor zoom coordinate conversion functions

* Convert zoom coordinates for text sources

* Rename variable

* Convert rect coordinate spaces

* Handle shadow DOM
This commit is contained in:
toasted-nutbread 2022-09-20 21:06:39 -04:00 committed by GitHub
parent ac373a6794
commit 480869c3d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 172 additions and 38 deletions

View File

@ -438,7 +438,8 @@
"layoutAwareScan",
"matchTypePrefix",
"hidePopupOnCursorExit",
"hidePopupOnCursorExitDelay"
"hidePopupOnCursorExitDelay",
"normalizeCssZoom"
],
"properties": {
"inputs": {
@ -706,6 +707,10 @@
"type": "number",
"minimum": 0,
"default": 0
},
"normalizeCssZoom": {
"type": "boolean",
"default": true
}
}
},

View File

@ -395,6 +395,7 @@ class Frontend {
this._textScanner.setOptions({
inputs: scanningOptions.inputs,
deepContentScan: scanningOptions.deepDomScan,
normalizeCssZoom: scanningOptions.normalizeCssZoom,
selectText: scanningOptions.selectText,
delay: scanningOptions.delay,
touchInputEnabled: scanningOptions.touchInputEnabled,

View File

@ -368,7 +368,7 @@ class Popup extends EventDispatcher {
* `valid` is `false` for `PopupProxy`, since the DOM node is hosted in a different frame.
*/
getFrameRect() {
const {left, top, right, bottom} = this._frame.getBoundingClientRect();
const {left, top, right, bottom} = this._getFrameBoundingClientRect();
return {left, top, right, bottom, valid: true};
}
@ -377,7 +377,7 @@ class Popup extends EventDispatcher {
* @returns {Promise<{width: number, height: number, valid: boolean}>} The size and whether or not it is valid.
*/
async getFrameSize() {
const {width, height} = this._frame.getBoundingClientRect();
const {width, height} = this._getFrameBoundingClientRect();
return {width, height, valid: true};
}
@ -680,12 +680,13 @@ class Popup extends EventDispatcher {
* @returns {SizeRect} The calculated rectangle for where to position the popup.
*/
_getPosition(sourceRects, writingMode, viewport) {
const scale = this._contentScale;
const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale;
this._frameSizeContentScale = scale;
sourceRects = this._convertSourceRectsCoordinateSpace(sourceRects);
const contentScale = this._contentScale;
const scaleRatio = this._frameSizeContentScale === null ? 1.0 : contentScale / this._frameSizeContentScale;
this._frameSizeContentScale = contentScale;
const frameRect = this._frame.getBoundingClientRect();
const frameWidth = Math.max(frameRect.width * scaleRatio, this._initialWidth * scale);
const frameHeight = Math.max(frameRect.height * scaleRatio, this._initialHeight * scale);
const frameWidth = Math.max(frameRect.width * scaleRatio, this._initialWidth * contentScale);
const frameHeight = Math.max(frameRect.height * scaleRatio, this._initialHeight * contentScale);
const horizontal = (writingMode === 'horizontal-tb' || this._verticalTextPosition === 'default');
let preferAfter;
@ -700,8 +701,8 @@ class Popup extends EventDispatcher {
horizontalOffset = this._horizontalOffset2;
verticalOffset = this._verticalOffset2;
}
horizontalOffset *= scale;
verticalOffset *= scale;
horizontalOffset *= contentScale;
verticalOffset *= contentScale;
let best = null;
const sourceRectsLength = sourceRects.length;
@ -955,4 +956,43 @@ class Popup extends EventDispatcher {
}
return false;
}
/**
* Gets the bounding client rect for the frame element, with a coordinate conversion applied.
* @returns {DOMRect} The rectangle of the frame.
*/
_getFrameBoundingClientRect() {
return DocumentUtil.convertRectZoomCoordinates(this._frame.getBoundingClientRect(), this._container);
}
/**
* Converts the coordinate space of source rectangles.
* @param {Rect[]} sourceRects The list of rectangles to convert.
* @returns {Rect[]} Either an updated list of rectangles, or `sourceRects` if no change is required.
*/
_convertSourceRectsCoordinateSpace(sourceRects) {
let scale = DocumentUtil.computeZoomScale(this._container);
if (scale === 1) { return sourceRects; }
scale = 1 / scale;
const sourceRects2 = [];
for (const rect of sourceRects) {
sourceRects2.push(this._createScaledRect(rect, scale));
}
return sourceRects2;
}
/**
* Creates a scaled rectangle.
* @param {Rect} rect The rectangle to scale.
* @param {number} scale The scale factor.
* @returns {Rect} A new rectangle which has been scaled.
*/
_createScaledRect(rect, scale) {
return {
left: rect.left * scale,
top: rect.top * scale,
right: rect.right * scale,
bottom: rect.bottom * scale
};
}
}

View File

@ -980,10 +980,12 @@ class OptionsUtil {
_updateVersion20(options) {
// Version 20 changes:
// Added anki.downloadTimeout.
// Added scanning.normalizeCssZoom.
// Fixed general.popupTheme invalid default.
// Fixed general.popupOuterTheme invalid default.
for (const profile of options.profiles) {
profile.options.anki.downloadTimeout = 0;
profile.options.scanning.normalizeCssZoom = true;
const {general} = profile.options;
if (general.popupTheme === 'default') {
general.popupTheme = 'light';

View File

@ -367,6 +367,7 @@ class Display extends EventDispatcher {
scanning: {
inputs: scanningOptions.inputs,
deepContentScan: scanningOptions.deepDomScan,
normalizeCssZoom: scanningOptions.normalizeCssZoom,
selectText: scanningOptions.selectText,
delay: scanningOptions.delay,
touchInputEnabled: scanningOptions.touchInputEnabled,
@ -1532,6 +1533,7 @@ class Display extends EventDispatcher {
}
}],
deepContentScan: scanningOptions.deepDomScan,
normalizeCssZoom: scanningOptions.normalizeCssZoom,
selectText: false,
delay: scanningOptions.delay,
touchInputEnabled: false,

View File

@ -24,10 +24,9 @@
class DocumentUtil {
constructor() {
this._transparentColorPattern = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/;
this._cssZoomSupported = (typeof document.createElement('div').style.zoom === 'string');
}
getRangeFromPoint(x, y, deepContentScan) {
getRangeFromPoint(x, y, {deepContentScan, normalizeCssZoom}) {
const elements = this._getElementsFromPoint(x, y, deepContentScan);
let imposter = null;
let imposterContainer = null;
@ -52,7 +51,7 @@ class DocumentUtil {
}
}
const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : []);
const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : [], normalizeCssZoom);
if (range !== null) {
if (imposter !== null) {
this._setImposterStyle(imposterContainer.style, 'z-index', '-2147483646');
@ -175,6 +174,60 @@ class DocumentUtil {
};
}
/**
* Computes the scaling adjustment that is necessary for client space coordinates based on the
* CSS zoom level.
* @param {Node} node A node in the document.
* @returns {number} The scaling factor.
*/
static computeZoomScale(node) {
if (this._cssZoomSupported === null) {
this._cssZoomSupported = (typeof document.createElement('div').style.zoom === 'string');
}
if (!this._cssZoomSupported) { return 1; }
// documentElement must be excluded because the computer style of its zoom property is inconsistent.
// * If CSS `:root{zoom:X;}` is specified, the computed zoom will always report `X`.
// * If CSS `:root{zoom:X;}` is not specified, the computed zoom report the browser's zoom level.
// Therefor, if CSS root zoom is specified as a value other than 1, the adjusted {x, y} values
// would be incorrect, which is not new behaviour.
let scale = 1;
const {ELEMENT_NODE, DOCUMENT_FRAGMENT_NODE} = Node;
const {documentElement} = document;
for (; node !== null && node !== documentElement; node = node.parentNode) {
const {nodeType} = node;
if (nodeType === DOCUMENT_FRAGMENT_NODE) {
const {host} = node;
if (typeof host !== 'undefined') {
node = host;
}
continue;
} else if (nodeType !== ELEMENT_NODE) {
continue;
}
let {zoom} = getComputedStyle(node);
if (typeof zoom !== 'string') { continue; }
zoom = Number.parseFloat(zoom);
if (!Number.isFinite(zoom) || zoom === 0) { continue; }
scale *= zoom;
}
return scale;
}
static convertRectZoomCoordinates(rect, node) {
const scale = this.computeZoomScale(node);
return (scale === 1 ? rect : new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale));
}
static convertMultipleRectZoomCoordinates(rects, node) {
const scale = this.computeZoomScale(node);
if (scale === 1) { return rects; }
const results = [];
for (const rect of rects) {
results.push(new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale));
}
return results;
}
static isPointInRect(x, y, rect) {
return (
x >= rect.left && x < rect.right &&
@ -435,7 +488,7 @@ class DocumentUtil {
return e !== null ? [e] : [];
}
_isPointInRange(x, y, range) {
_isPointInRange(x, y, range, normalizeCssZoom) {
// Require a text node to start
const {startContainer} = range;
if (startContainer.nodeType !== Node.TEXT_NODE) {
@ -443,8 +496,10 @@ class DocumentUtil {
}
// Convert CSS zoom coordinates
if (this._cssZoomSupported) {
({x, y} = this._convertCssZoomCoordinates(x, y, startContainer));
if (normalizeCssZoom) {
const scale = DocumentUtil.computeZoomScale(startContainer);
x /= scale;
y /= scale;
}
// Scan forward
@ -583,7 +638,7 @@ class DocumentUtil {
}
}
_caretRangeFromPointExt(x, y, elements) {
_caretRangeFromPointExt(x, y, elements, normalizeCssZoom) {
let previousStyles = null;
try {
let i = 0;
@ -596,7 +651,7 @@ class DocumentUtil {
const startContainer = range.startContainer;
if (startContinerPre !== startContainer) {
if (this._isPointInRange(x, y, range)) {
if (this._isPointInRange(x, y, range, normalizeCssZoom)) {
return range;
}
startContinerPre = startContainer;
@ -668,18 +723,6 @@ class DocumentUtil {
_isElementUserSelectAll(element) {
return getComputedStyle(element).userSelect === 'all';
}
_convertCssZoomCoordinates(x, y, node) {
const ELEMENT_NODE = Node.ELEMENT_NODE;
for (; node !== null; node = node.parentNode) {
if (node.nodeType !== ELEMENT_NODE) { continue; }
let {zoom} = getComputedStyle(node);
if (typeof zoom !== 'string') { continue; }
zoom = Number.parseFloat(zoom);
if (!Number.isFinite(zoom) || zoom === 0) { continue; }
x /= zoom;
y /= zoom;
}
return {x, y};
}
}
// eslint-disable-next-line no-underscore-dangle
DocumentUtil._cssZoomSupported = null;

View File

@ -16,6 +16,7 @@
*/
/* global
* DocumentUtil
* StringUtil
*/
@ -95,11 +96,11 @@ class TextSourceElement {
}
getRect() {
return this._element.getBoundingClientRect();
return DocumentUtil.convertRectZoomCoordinates(this._element.getBoundingClientRect(), this._element);
}
getRects() {
return this._element.getClientRects();
return DocumentUtil.convertMultipleRectZoomCoordinates(this._element.getClientRects(), this._element);
}
getWritingMode() {

View File

@ -91,11 +91,11 @@ class TextSourceRange {
}
getRect() {
return this._range.getBoundingClientRect();
return DocumentUtil.convertRectZoomCoordinates(this._range.getBoundingClientRect(), this._range.startContainer);
}
getRects() {
return this._range.getClientRects();
return DocumentUtil.convertMultipleRectZoomCoordinates(this._range.getClientRects(), this._range.startContainer);
}
getWritingMode() {

View File

@ -54,6 +54,7 @@ class TextScanner extends EventDispatcher {
this._selectionRestoreInfo = null;
this._deepContentScan = false;
this._normalizeCssZoom = true;
this._selectText = false;
this._delay = 0;
this._touchInputEnabled = false;
@ -151,6 +152,7 @@ class TextScanner extends EventDispatcher {
setOptions({
inputs,
deepContentScan,
normalizeCssZoom,
selectText,
delay,
touchInputEnabled,
@ -167,6 +169,9 @@ class TextScanner extends EventDispatcher {
if (typeof deepContentScan === 'boolean') {
this._deepContentScan = deepContentScan;
}
if (typeof normalizeCssZoom === 'boolean') {
this._normalizeCssZoom = normalizeCssZoom;
}
if (typeof selectText === 'boolean') {
this._selectText = selectText;
}
@ -932,7 +937,10 @@ class TextScanner extends EventDispatcher {
return;
}
const textSource = this._documentUtil.getRangeFromPoint(x, y, this._deepContentScan);
const textSource = this._documentUtil.getRangeFromPoint(x, y, {
deepContentScan: this._deepContentScan,
normalizeCssZoom: this._normalizeCssZoom
});
try {
await this._search(textSource, searchTerms, searchKanji, inputInfo);
} finally {

View File

@ -511,6 +511,34 @@
<label class="toggle"><input type="checkbox" data-setting="scanning.deepDomScan"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
</div>
</div></div>
<div class="settings-item advanced-only">
<div class="settings-item-inner">
<div class="settings-item-left">
<div class="settings-item-label">Normalize CSS zoom</div>
<div class="settings-item-description">
Correct the pointer location on webpages where CSS <code>zoom</code> is used.
<a tabindex="0" class="more-toggle more-only" data-parent-distance="4">More&hellip;</a>
</div>
</div>
<div class="settings-item-right">
<label class="toggle"><input type="checkbox" data-setting="scanning.normalizeCssZoom"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
</div>
</div>
<div class="settings-item-children more" hidden>
<p>
The non-standard CSS <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/zoom" target="_blank" rel="noopener noreferrer"><code>zoom</code></a> property interferes with the normal calculation of the pointer coordinates when scanning webpages. This property is discouraged from being used and its use is rare, but some webpages may still use it.
</p>
<p>
Enabling this option, which is on by default, will take the value of this property into account when scanning webpage content. It is currently put behind an option in case there are unforeseen negative side effects.
</p>
<p>
This setting does not have any effect in Firefox, as it does not implement the <code>zoom</code> property.
</p>
<p>
<a tabindex="0" class="more-toggle" data-parent-distance="3">Less&hellip;</a>
</p>
</div>
</div>
<div class="settings-item advanced-only">
<div class="settings-item-inner">
<div class="settings-item-left">

View File

@ -167,7 +167,10 @@ async function testDocumentTextScanningFunctions(dom, {DocumentUtil, TextSourceR
// Test docRangeFromPoint
const documentUtil = new DocumentUtil();
const source = documentUtil.getRangeFromPoint(0, 0, false);
const source = documentUtil.getRangeFromPoint(0, 0, {
deepContentScan: false,
normalizeCssZoom: true
});
switch (resultType) {
case 'TextSourceRange':
assert.strictEqual(getPrototypeOfOrNull(source), TextSourceRange.prototype);

View File

@ -347,6 +347,7 @@ function createProfileOptionsUpdatedTestData1() {
matchTypePrefix: false,
hidePopupOnCursorExit: false,
hidePopupOnCursorExitDelay: 0,
normalizeCssZoom: true,
preventMiddleMouse: {
onWebPages: false,
onPopupPages: false,