From b787073a733714341c9115b038c53b56e41738c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claud=C3=A9ric=20Demers?= Date: Fri, 13 Sep 2024 17:18:41 -0400 Subject: [PATCH] PositionObserver bug fixes --- .../core/plugins/scrolling/ScrollListener.ts | 9 +- .../src/core/plugins/scrolling/Scroller.ts | 3 +- .../bounding-rectangle/PositionObserver.ts | 130 +++++++++--------- .../getVisibleBoundingRectangle.ts | 53 +++++++ .../bounding-rectangle/isRectEqual.ts | 2 +- .../src/utilities/bounding-rectangle/types.ts | 4 + 6 files changed, 128 insertions(+), 73 deletions(-) create mode 100644 packages/dom/src/utilities/bounding-rectangle/getVisibleBoundingRectangle.ts create mode 100644 packages/dom/src/utilities/bounding-rectangle/types.ts diff --git a/packages/dom/src/core/plugins/scrolling/ScrollListener.ts b/packages/dom/src/core/plugins/scrolling/ScrollListener.ts index cafad99c..cdab77ff 100644 --- a/packages/dom/src/core/plugins/scrolling/ScrollListener.ts +++ b/packages/dom/src/core/plugins/scrolling/ScrollListener.ts @@ -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'; @@ -20,10 +20,12 @@ export class ScrollListener extends CorePlugin { 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 @@ -37,7 +39,6 @@ export class ScrollListener extends CorePlugin { if (this.#timeout == null) { this.#timeout = setTimeout(() => { this.manager.collisionObserver.forceUpdate(); - this.#timeout = undefined; }, 50); } diff --git a/packages/dom/src/core/plugins/scrolling/Scroller.ts b/packages/dom/src/core/plugins/scrolling/Scroller.ts index f2848bc5..047ba635 100644 --- a/packages/dom/src/core/plugins/scrolling/Scroller.ts +++ b/packages/dom/src/core/plugins/scrolling/Scroller.ts @@ -115,7 +115,8 @@ export class Scroller extends CorePlugin { 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 => { diff --git a/packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts b/packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts index 931bd272..8d0d25f1 100644 --- a/packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts +++ b/packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts @@ -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, @@ -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, } ); @@ -80,6 +70,7 @@ export class PositionObserver { public boundingClientRect: DOMRectReadOnly; public disconnect() { + this.#disconnected = true; this.#resizeObserver?.disconnect(); this.#positionObserver?.disconnect(); this.#visibilityObserver.disconnect(); @@ -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, } @@ -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; +} diff --git a/packages/dom/src/utilities/bounding-rectangle/getVisibleBoundingRectangle.ts b/packages/dom/src/utilities/bounding-rectangle/getVisibleBoundingRectangle.ts new file mode 100644 index 00000000..1621d7e4 --- /dev/null +++ b/packages/dom/src/utilities/bounding-rectangle/getVisibleBoundingRectangle.ts @@ -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; +} diff --git a/packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts b/packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts index 742bc114..5204ecae 100644 --- a/packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts +++ b/packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts @@ -1,4 +1,4 @@ -type Rect = Pick; +import type {Rect} from './types.ts'; export function isRectEqual(a: Rect | undefined, b: Rect | undefined) { if (a === b) return true; diff --git a/packages/dom/src/utilities/bounding-rectangle/types.ts b/packages/dom/src/utilities/bounding-rectangle/types.ts new file mode 100644 index 00000000..3743884f --- /dev/null +++ b/packages/dom/src/utilities/bounding-rectangle/types.ts @@ -0,0 +1,4 @@ +export type Rect = Pick< + DOMRect, + 'top' | 'left' | 'right' | 'bottom' | 'width' | 'height' +>;