Skip to content

Commit

Permalink
fix(create-grid): include elements scrolled out of view in the grid (#…
Browse files Browse the repository at this point in the history
…3773)

* upload work

* Clean up grid code

* Clean up grid code

* Add grid class

* Add doc blocks

* Add vorizontal scroll test

* Assert on grid boundary existing

* Fix typo

* typo

* add scrolling integration test

* remove async

* Doc fix

* Docs! :-(

* Tweak as suggested

Co-authored-by: Steven Lambert <[email protected]>
  • Loading branch information
WilcoFiers and straker authored Nov 14, 2022
1 parent ab877f9 commit a563263
Show file tree
Hide file tree
Showing 18 changed files with 493 additions and 169 deletions.
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

0 comments on commit a563263

Please sign in to comment.