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 13, 2024
1 parent b0fc06f commit b787073
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 73 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
130 changes: 63 additions & 67 deletions packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import {isRectEqual} from './isRectEqual.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 {getVisibleBoundingRectangle} from './getVisibleBoundingRectangle.ts';
import {Rect} from './types.ts';

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

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,
];

export class PositionObserver {
constructor(
private element: Element,
Expand All @@ -18,58 +20,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 +70,7 @@ export class PositionObserver {
public boundingClientRect: DOMRectReadOnly;

public disconnect() {
this.#disconnected = true;
this.#resizeObserver?.disconnect();
this.#positionObserver?.disconnect();
this.#visibilityObserver.disconnect();
Expand All @@ -88,59 +79,49 @@ 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 = () => {
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;
const intersectionRatio = getIntersectionRatio(
intersectionRect,
visibleRect
);

if (intersectionRatio !== 1) {
this.#observePosition();
}
},
{
threshold: [0, 1],
threshold: 1,
rootMargin,
root,
}
Expand All @@ -151,23 +132,38 @@ export class PositionObserver {
};

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

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`;
}
}
}

function getIntersectionRatio(a: Rect, b: Rect) {
const ratio =
(a.top / b.top +
a.left / b.left +
a.width / b.width +
a.height / b.height) /
4;

return isNaN(ratio) ? 0 : ratio;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type {Rect} from './types.ts';

export function getVisibleBoundingRectangle(
element: Element,
boundingClientRect = element.getBoundingClientRect()
): Rect {
// Get the initial bounding client rect of the element
let rect: Rect = boundingClientRect;
const ownerWindow = element.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 !== document.documentElement) {
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;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type Rect = Pick<DOMRectReadOnly, 'top' | 'left' | 'right' | 'bottom'>;
import type {Rect} from './types.ts';

export function isRectEqual(a: Rect | undefined, b: Rect | undefined) {
if (a === b) return true;
Expand Down
4 changes: 4 additions & 0 deletions packages/dom/src/utilities/bounding-rectangle/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Rect = Pick<
DOMRect,
'top' | 'left' | 'right' | 'bottom' | 'width' | 'height'
>;

0 comments on commit b787073

Please sign in to comment.