Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(create-grid): include elements scrolled out of view in the grid #3773

Merged
merged 14 commits into from
Nov 14, 2022
128 changes: 93 additions & 35 deletions lib/commons/dom/create-grid.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/* eslint no-bitwise: 0 */
import isVisibleOnScreen from './is-visible-on-screen';
import { getBoundingRect } from '../math/get-bounding-rect';
import { isPointInRect } from '../math/is-point-in-rect';
import VirtualNode from '../../core/base/virtual-node/virtual-node';
import { getNodeFromTree, getScroll, isShadowRoot } from '../../core/utils';
import constants from '../../core/constants';
import cache from '../../core/base/cache';
import assert from '../../core/utils/assert';

/**
* Setup the 2d grid and add every element to it, even elements not
Expand All @@ -12,10 +14,7 @@ import cache from '../../core/base/cache';
*/
export default function createGrid(
root = document.body,
rootGrid = {
container: null,
cells: []
},
rootGrid,
parentVNode = null
) {
// Prevent multiple calls per run
Expand All @@ -34,13 +33,11 @@ export default function createGrid(
}

vNode._stackingOrder = [0];
rootGrid ??= new Grid();
addNodeToGrid(rootGrid, vNode);

if (getScroll(vNode.actualNode)) {
const subGrid = {
container: vNode,
cells: []
};
const subGrid = new Grid(vNode);
vNode._subGrid = subGrid;
}
}
Expand Down Expand Up @@ -76,10 +73,7 @@ export default function createGrid(
const grid = scrollRegionParent ? scrollRegionParent._subGrid : rootGrid;

if (getScroll(vNode.actualNode)) {
const subGrid = {
container: vNode,
cells: []
};
const subGrid = new Grid(vNode);
vNode._subGrid = subGrid;
}

Expand Down Expand Up @@ -323,36 +317,100 @@ function findScrollRegionParent(vNode, parentVNode) {
* @param {VirtualNode}
*/
function addNodeToGrid(grid, vNode) {
const gridSize = constants.gridSize;
vNode.clientRects.forEach(rect => {
if (rect.right <= 0 || rect.bottom <= 0) {
return;
}
// save a reference to where this element is in the grid so we
// can find it even if it's in a subgrid
vNode._grid ??= grid;
const x = rect.left;
const y = rect.top;
const gridRect = grid.getGridPositionOfRect(rect);
grid.loopGridPosition(gridRect, gridCell => {
if (!gridCell.includes(vNode)) {
gridCell.push(vNode);
}
});
});
}

// "| 0" is a faster way to do Math.floor
// @see https://jsperf.com/math-floor-vs-math-round-vs-parseint/152
const startRow = (y / gridSize) | 0;
const startCol = (x / gridSize) | 0;
const endRow = ((y + rect.height) / gridSize) | 0;
const endCol = ((x + rect.width) / gridSize) | 0;
class Grid {
constructor(container = null) {
this.container = container;
this.cells = [];
}

grid.numCols = Math.max(grid.numCols ?? 0, endCol);
/**
* Convert x or y coordinate from rect, to a position in the grid
* @param {number}
* @returns {number}
*/
toGridIndex(num) {
return Math.floor(num / constants.gridSize);
}

for (let row = startRow; row <= endRow; row++) {
grid.cells[row] = grid.cells[row] || [];
/**
* Return an an array of nodes available at a particular grid coordinate
* @param {DOMPoint} gridPosition
* @returns {Array<AbstractVirtualNode>}
*/
getCellFromPoint({ x, y }) {
assert(this.boundaries, 'Grid does not have cells added');
const rowIndex = this.toGridIndex(y);
const colIndex = this.toGridIndex(x);
assert(
isPointInRect({ y: rowIndex, x: colIndex }, this.boundaries),
'Element midpoint exceeds the grid bounds'
);
const row = this.cells[rowIndex - this.cells._negativeIndex] ?? [];
return row[colIndex - row._negativeIndex] ?? [];
}

for (let col = startCol; col <= endCol; col++) {
grid.cells[row][col] = grid.cells[row][col] || [];
/**
* Loop over all cells within the gridPosition rect
* @param {DOMRect} gridPosition
* @param {Function} callback
*/
loopGridPosition(gridPosition, callback) {
const { left, right, top, bottom } = gridPosition;
if (this.boundaries) {
gridPosition = getBoundingRect(this.boundaries, gridPosition);
}
this.boundaries = gridPosition;

if (!grid.cells[row][col].includes(vNode)) {
grid.cells[row][col].push(vNode);
}
}
loopNegativeIndexMatrix(this.cells, top, bottom, (gridRow, row) => {
loopNegativeIndexMatrix(gridRow, left, right, (gridCell, col) => {
callback(gridCell, { row, col });
});
});
}

/**
* Scale the rect to the position within the grid
* @param {DOMRect} clientOrBoundingRect
* @param {number} margin Offset outside the rect, default 0
* @returns {DOMRect} gridPosition
*/
getGridPositionOfRect({ top, right, bottom, left }, margin = 0) {
top = this.toGridIndex(top - margin);
right = this.toGridIndex(right + margin - 1);
bottom = this.toGridIndex(bottom + margin - 1);
left = this.toGridIndex(left - margin);
return new window.DOMRect(left, top, right - left, bottom - top);
}
}

// handle negative row/col values
function loopNegativeIndexMatrix(matrix, start, end, callback) {
matrix._negativeIndex ??= 0;
// Shift the array when start is negative
if (start < matrix._negativeIndex) {
for (let i = 0; i < matrix._negativeIndex - start; i++) {
matrix.splice(0, 0, []);
}
});
matrix._negativeIndex = start;
}

const startOffset = start - matrix._negativeIndex;
const endOffset = end - matrix._negativeIndex;
for (let index = startOffset; index <= endOffset; index++) {
matrix[index] ??= [];
callback(matrix[index], index + matrix._negativeIndex);
}
}
46 changes: 14 additions & 32 deletions lib/commons/dom/find-nearby-elms.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,32 @@ import createGrid from './create-grid';
import { memoize } from '../../core/utils';

export default function findNearbyElms(vNode, margin = 0) {
/*eslint no-bitwise: 0*/
const gridSize = createGrid();
const selfIsFixed = hasFixedPosition(vNode);
createGrid(); // Ensure grid exists
if (!vNode._grid?.cells?.length) {
return []; // Elements not in the grid don't have ._grid
}

const rect = vNode.boundingClientRect;
const gridCells = vNode._grid.cells;
const boundaries = {
topRow: ((rect.top - margin) / gridSize) | 0,
bottomRow: ((rect.bottom + margin) / gridSize) | 0,
leftCol: ((rect.left - margin) / gridSize) | 0,
rightCol: ((rect.right + margin) / gridSize) | 0
};
const grid = vNode._grid;
const selfIsFixed = hasFixedPosition(vNode);
const gridPosition = grid.getGridPositionOfRect(rect, margin);

const neighbors = [];
loopGridCells(gridCells, boundaries, vNeighbor => {
if (
vNeighbor &&
vNeighbor !== vNode &&
!neighbors.includes(vNeighbor) &&
selfIsFixed === hasFixedPosition(vNeighbor)
) {
neighbors.push(vNeighbor);
grid.loopGridPosition(gridPosition, vNeighbors => {
for (const vNeighbor of vNeighbors) {
if (
vNeighbor &&
vNeighbor !== vNode &&
!neighbors.includes(vNeighbor) &&
selfIsFixed === hasFixedPosition(vNeighbor)
) {
neighbors.push(vNeighbor);
}
}
});

return neighbors;
}

function loopGridCells(gridCells, boundaries, cb) {
const { topRow, bottomRow, leftCol, rightCol } = boundaries;
for (let row = topRow; row <= bottomRow; row++) {
for (let col = leftCol; col <= rightCol; col++) {
// Don't loop on elements outside the grid
const length = gridCells[row]?.[col]?.length ?? -1;
for (let i = 0; i < length; i++) {
cb(gridCells[row][col][i]);
}
}
}
}

const hasFixedPosition = memoize(vNode => {
if (!vNode) {
return false;
Expand Down
74 changes: 23 additions & 51 deletions lib/commons/dom/get-rect-stack.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,29 @@
/* eslint no-bitwise: 0 */
import visuallySort from './visually-sort';
import constants from '../../core/constants';
import { getRectCenter } from '../math';

export function getRectStack(grid, rect, recursed = false) {
// use center point of rect
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const floorX = floor(x);
const floorY = floor(y);

// NOTE: there is a very rare edge case in Chrome vs Firefox that can
// return different results of `document.elementsFromPoint`. If the center
// point of the element is <1px outside of another elements bounding rect,
// Chrome appears to round the number up and return the element while Firefox
// keeps the number as is and won't return the element. In this case, we
// went with pixel perfect collision rather than rounding
const row = floor(y / constants.gridSize);
const col = floor(x / constants.gridSize);

// we're making an assumption that there cannot be an element in the
// grid which escapes the grid bounds. For example, if the grid is 4x4 there
// can't be an element whose midpoint is at column 5. If this happens this
// means there's an error in our grid logic that needs to be fixed
if (row > grid.cells.length || col > grid.numCols) {
throw new Error('Element midpoint exceeds the grid bounds');
}

// it is acceptable if a row has empty cells due to client rects not filling
// the entire bounding rect of an element
// @see https://github.com/dequelabs/axe-core/issues/3166
let stack =
grid.cells[row][col]?.filter(gridCellNode => {
return gridCellNode.clientRects.find(clientRect => {
const rectX = clientRect.left;
const rectY = clientRect.top;

// perform an AABB (axis-aligned bounding box) collision check for the
// point inside the rect
// account for differences in how browsers handle floating point
// precision of bounding rects
return (
floorX < floor(rectX + clientRect.width) &&
floorX >= floor(rectX) &&
floorY < floor(rectY + clientRect.height) &&
floorY >= floor(rectY)
);
});
}) ?? [];
const center = getRectCenter(rect);
const gridCell = grid.getCellFromPoint(center) || [];

const floorX = Math.floor(center.x);
const floorY = Math.floor(center.y);
let stack = gridCell.filter(gridCellNode => {
return gridCellNode.clientRects.some(clientRect => {
const rectX = clientRect.left;
const rectY = clientRect.top;

// perform an AABB (axis-aligned bounding box) collision check for the
// point inside the rect
// account for differences in how browsers handle floating point
// precision of bounding rects
return (
floorX < Math.floor(rectX + clientRect.width) &&
floorX >= Math.floor(rectX) &&
floorY < Math.floor(rectY + clientRect.height) &&
floorY >= Math.floor(rectY)
);
});
});

const gridContainer = grid.container;
if (gridContainer) {
Expand All @@ -69,8 +46,3 @@ export function getRectStack(grid, rect, recursed = false) {

return stack;
}

// equivalent to Math.floor(float) but is slightly faster
function floor(float) {
return float | 0;
}
Loading