Skip to content

Commit

Permalink
PositionObserver bug fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
clauderic committed Sep 16, 2024
1 parent b0fc06f commit 2956ca7
Show file tree
Hide file tree
Showing 10 changed files with 199 additions and 84 deletions.
9 changes: 5 additions & 4 deletions packages/dom/src/core/plugins/scrolling/ScrollListener.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {DragOperationStatus, CorePlugin} from '@dnd-kit/abstract';
import {CorePlugin} from '@dnd-kit/abstract';
import {effect} from '@dnd-kit/state';

import type {DragDropManager} from '../../manager/index.ts';
Expand All @@ -20,10 +20,12 @@ export class ScrollListener extends CorePlugin<DragDropManager> {
const enabled = dragOperation.status.dragging;

if (enabled) {
document.addEventListener('scroll', this.handleScroll, listenerOptions);
const root = dragOperation.source?.element?.ownerDocument ?? document;

root.addEventListener('scroll', this.handleScroll, listenerOptions);

return () => {
document.removeEventListener(
root.removeEventListener(
'scroll',
this.handleScroll,
listenerOptions
Expand All @@ -37,7 +39,6 @@ export class ScrollListener extends CorePlugin<DragDropManager> {
if (this.#timeout == null) {
this.#timeout = setTimeout(() => {
this.manager.collisionObserver.forceUpdate();

this.#timeout = undefined;
}, 50);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/dom/src/core/plugins/scrolling/Scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ export class Scroller extends CorePlugin<DragDropManager> {

const {element, by} = this.#meta;

element.scrollBy(by.x, by.y);
if (by.y) element.scrollTop += by.y;
if (by.x) element.scrollLeft += by.x;
};

public scroll = (options?: {by: Coordinates}): boolean => {
Expand Down
135 changes: 64 additions & 71 deletions packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import {isRectEqual} from './isRectEqual.ts';
import {BoundingRectangle, Rectangle} from '@dnd-kit/geometry';

import {throttle} from '../scheduling/throttle.ts';

const THRESHOLD = [
0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65,
0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99, 1,
];
import {isRectEqual} from './isRectEqual.ts';
import {getVisibleBoundingRectangle} from './getVisibleBoundingRectangle.ts';

type PositionObserverCallback = (
boundingClientRect: DOMRectReadOnly | null
boundingClientRect: BoundingRectangle | null
) => void;

const threshold = Array.from({length: 100}, (_, index) => index / 100);
const THROTTLE_INTERVAL = 75;

export class PositionObserver {
constructor(
private element: Element,
Expand All @@ -18,58 +21,46 @@ export class PositionObserver {
this.#callback = callback;
this.boundingClientRect = element.getBoundingClientRect();

const root = element.ownerDocument;

if (options?.debug) {
this.#debug = document.createElement('div');
this.#debug.style.background = 'rgba(0,0,0,0.15)';
this.#debug.style.position = 'fixed';
this.#debug.style.pointerEvents = 'none';
element.ownerDocument.body.appendChild(this.#debug);
root.body.appendChild(this.#debug);
}

this.#visibilityObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
const entry = entries[entries.length - 1];
const {
boundingClientRect,
intersectionRect,
isIntersecting: visible,
intersectionRatio,
} = entry;
const {boundingClientRect, isIntersecting: visible} = entry;
const {width, height} = boundingClientRect;
const previousVisible = this.#visible;

if (!width && !height) return;

if (intersectionRatio < 1 && intersectionRatio > 0) {
this.#visibleRect = intersectionRect;
this.#offsetLeft = intersectionRect.left - boundingClientRect.left;
this.#offsetTop = intersectionRect.top - boundingClientRect.top;
} else {
this.#visibleRect = undefined;
this.#offsetLeft = 0;
this.#offsetTop = 0;
}
this.#visible = visible;

this.#observePosition();
if (!width && !height) return;

if (this.#visible && !visible) {
if (previousVisible && !visible) {
this.#positionObserver?.disconnect();
this.#callback(null);
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;

if (this.#debug) this.#debug.style.visibility = 'hidden';
} else {
this.#observePosition();
}

if (visible && !this.#resizeObserver) {
this.#resizeObserver = new ResizeObserver(this.#observePosition);
this.#resizeObserver.observe(element);
}

this.#visible = visible;
},
{
threshold: THRESHOLD,
root: element.ownerDocument ?? document,
threshold,
root,
}
);

Expand All @@ -80,6 +71,7 @@ export class PositionObserver {
public boundingClientRect: DOMRectReadOnly;

public disconnect() {
this.#disconnected = true;
this.#resizeObserver?.disconnect();
this.#positionObserver?.disconnect();
this.#visibilityObserver.disconnect();
Expand All @@ -88,86 +80,87 @@ export class PositionObserver {

#callback: PositionObserverCallback;
#visible = true;
#offsetTop = 0;
#offsetLeft = 0;
#visibleRect: DOMRectReadOnly | undefined;
#previousBoundingClientRect: DOMRectReadOnly | undefined;
#resizeObserver: ResizeObserver | undefined;
#positionObserver: IntersectionObserver | undefined;
#visibilityObserver: IntersectionObserver;
#debug: HTMLElement | undefined;
#disconnected = false;

#observePosition = () => {
#observePosition = throttle(() => {
const {element} = this;

if (!element.isConnected) {
this.disconnect();
this.#positionObserver?.disconnect();

if (this.#disconnected || !this.#visible || !element.isConnected) {
return;
}

const root = element.ownerDocument ?? document;
const {innerHeight, innerWidth} = root.defaultView ?? window;
const {width, height} = this.#visibleRect ?? this.boundingClientRect;
const rect = element.getBoundingClientRect();
const top = rect.top + this.#offsetTop;
const left = rect.left + this.#offsetLeft;
const bottom = top + height;
const right = left + width;
const insetTop = Math.floor(top);
const insetLeft = Math.floor(left);
const insetRight = Math.floor(innerWidth - right);
const insetBottom = Math.floor(innerHeight - bottom);
const rootMargin = `${-insetTop}px ${-insetRight}px ${-insetBottom}px ${-insetLeft}px`;

this.#positionObserver?.disconnect();
const clientRect = element.getBoundingClientRect();
const visibleRect = getVisibleBoundingRectangle(element, clientRect);
const {top, left, bottom, right} = visibleRect;
const insetTop = -Math.floor(top);
const insetLeft = -Math.floor(left);
const insetRight = -Math.floor(innerWidth - right);
const insetBottom = -Math.floor(innerHeight - bottom);
const rootMargin = `${insetTop}px ${insetRight}px ${insetBottom}px ${insetLeft}px`;

this.boundingClientRect = clientRect;
this.#positionObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
const {boundingClientRect, intersectionRatio} = entry;

const previous = this.boundingClientRect;
this.boundingClientRect =
intersectionRatio === 1
? boundingClientRect
: element.getBoundingClientRect();

if (
previous.width > width ||
previous.height > height ||
!isRectEqual(this.boundingClientRect, previous)
) {
const {intersectionRect} = entry;
/*
* The intersection ratio returned by the intersection observer entry
* represents the ratio of the intersectionRect to the boundingClientRect,
* which is not what we want. We want the ratio of the intersectionRect
* to the rootBounds (visible rect).
*/
const intersectionRatio = Rectangle.intersectionRatio(
intersectionRect,
visibleRect
);

if (intersectionRatio !== 1) {
this.#observePosition();
}
},
{
threshold: [0, 1],
threshold,
rootMargin,
root,
}
);

this.#positionObserver.observe(element);
this.#notify();
};
}, THROTTLE_INTERVAL);

#notify() {
this.#updateDebug();

async #notify() {
if (isRectEqual(this.boundingClientRect, this.#previousBoundingClientRect))
return;

this.#updateDebug();
this.#callback(this.boundingClientRect);
this.#previousBoundingClientRect = this.boundingClientRect;
}

#updateDebug() {
if (this.#debug) {
const {top, left, width, height} = this.boundingClientRect;
const {top, left, width, height} = getVisibleBoundingRectangle(
this.element
);

this.#debug.style.overflow = 'hidden';
this.#debug.style.visibility = 'visible';
this.#debug.style.top = `${top}px`;
this.#debug.style.left = `${left}px`;
this.#debug.style.width = `${width}px`;
this.#debug.style.height = `${height}px`;
this.#debug.style.top = `${Math.floor(top)}px`;
this.#debug.style.left = `${Math.floor(left)}px`;
this.#debug.style.width = `${Math.floor(width)}px`;
this.#debug.style.height = `${Math.floor(height)}px`;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type {BoundingRectangle} from '@dnd-kit/geometry';

import {getDocument} from '../execution-context/index.ts';

/**
* Returns the bounding rectangle of the viewport
* @param element
* @returns BoundingRectangle
*/
export function getViewportBoundingRectangle(
element: Element
): BoundingRectangle {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {BoundingRectangle} from '@dnd-kit/geometry';

import {isOverflowVisible} from './isOverflowVisible.ts';

/*
* Get the currently visible bounding rectangle of an element
* @param element
* @param boundingClientRect
* @returns Rect
*/
export function getVisibleBoundingRectangle(
element: Element,
boundingClientRect = element.getBoundingClientRect()
): BoundingRectangle {
// Get the initial bounding client rect of the element
let rect: BoundingRectangle = boundingClientRect;
const {ownerDocument} = element;
const ownerWindow = ownerDocument.defaultView ?? window;

// Traverse up the DOM tree to clip the rect based on ancestors' bounding rects
let ancestor: HTMLElement | null = element.parentElement;

while (ancestor && ancestor !== ownerDocument.documentElement) {
if (!isOverflowVisible(ancestor)) {
const ancestorRect = ancestor.getBoundingClientRect();
// Clip the rect based on the ancestor's bounding rect
rect = {
top: Math.max(rect.top, ancestorRect.top),
right: Math.min(rect.right, ancestorRect.right),
bottom: Math.min(rect.bottom, ancestorRect.bottom),
left: Math.max(rect.left, ancestorRect.left),
width: 0, // Will be calculated next
height: 0, // Will be calculated next
};

// Calculate the width and height after clipping
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
}

// Move to the next ancestor
ancestor = ancestor.parentElement;
}

// Clip the rect based on the viewport (window)
const viewportWidth = ownerWindow.innerWidth;
const viewportHeight = ownerWindow.innerHeight;

rect = {
top: Math.max(rect.top, 0),
right: Math.min(rect.right, viewportWidth),
bottom: Math.min(rect.bottom, viewportHeight),
left: Math.max(rect.left, 0),
width: 0, // Will be calculated next
height: 0, // Will be calculated next
};

// Calculate the width and height after clipping
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;

return rect;
}
16 changes: 16 additions & 0 deletions packages/dom/src/utilities/bounding-rectangle/isOverflowVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Check if an element has visible overflow.
* @param element
* @param style
* @returns boolean
*/
export function isOverflowVisible(
element: Element,
style = getComputedStyle(element)
) {
const {overflow, overflowX, overflowY} = style;

return (
overflow === 'visible' && overflowX === 'visible' && overflowY === 'visible'
);
}
7 changes: 5 additions & 2 deletions packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
type Rect = Pick<DOMRectReadOnly, 'top' | 'left' | 'right' | 'bottom'>;
import type {BoundingRectangle} from '@dnd-kit/geometry';

export function isRectEqual(a: Rect | undefined, b: Rect | undefined) {
export function isRectEqual(
a: BoundingRectangle | undefined,
b: BoundingRectangle | undefined
) {
if (a === b) return true;
if (!a || !b) return false;

Expand Down
Loading

0 comments on commit 2956ca7

Please sign in to comment.