From 5ca3a712527f9a32234d5390de060d878722a726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claud=C3=A9ric=20Demers?= Date: Thu, 12 Sep 2024 13:56:15 -0400 Subject: [PATCH] PositionObserver rework --- .changeset/collision-observer.md | 3 +- .../abstract/src/core/collision/observer.ts | 22 +- .../src/core/entities/droppable/droppable.ts | 4 - .../src/core/entities/droppable/droppable.ts | 150 ++++--------- .../dom/src/core/plugins/feedback/Feedback.ts | 165 +++++++++++---- .../src/sortable/SortableKeyboardPlugin.ts | 48 +++-- .../bounding-rectangle/PositionObserver.ts | 198 ++++++++++++++++++ .../src/utilities/bounding-rectangle/index.ts | 1 + .../bounding-rectangle/isRectEqual.ts | 13 ++ .../utilities/element/createPlaceholder.ts | 28 --- packages/dom/src/utilities/index.ts | 6 +- .../utilities/keyframes/getFinalKeyframe.ts | 16 -- .../scroll/scrollIntoViewIfNeeded.ts | 4 +- .../dom/src/utilities/shapes/DOMRectangle.ts | 93 +++++++- .../utilities/transform/computeTranslate.ts | 28 ++- packages/react/src/core/context/renderer.ts | 14 +- 16 files changed, 527 insertions(+), 266 deletions(-) create mode 100644 packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts create mode 100644 packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts delete mode 100644 packages/dom/src/utilities/element/createPlaceholder.ts delete mode 100644 packages/dom/src/utilities/keyframes/getFinalKeyframe.ts diff --git a/.changeset/collision-observer.md b/.changeset/collision-observer.md index b56e2cb3..d43df6f7 100644 --- a/.changeset/collision-observer.md +++ b/.changeset/collision-observer.md @@ -1,5 +1,6 @@ --- '@dnd-kit/abstract': patch +'@dnd-kit/dom': patch --- -Reduce the how frequently collisions are re-computed when the position of the drag operation does not change. +Rework how collisions are detected and how the position of elements is observed using a new `PositionObserver`. diff --git a/packages/abstract/src/core/collision/observer.ts b/packages/abstract/src/core/collision/observer.ts index 8a651ec4..a8b047e9 100644 --- a/packages/abstract/src/core/collision/observer.ts +++ b/packages/abstract/src/core/collision/observer.ts @@ -1,4 +1,4 @@ -import {batch, signal, untracked, type Signal, effects} from '@dnd-kit/state'; +import {signal, untracked, type Signal, effects} from '@dnd-kit/state'; import type {Coordinates} from '@dnd-kit/geometry'; import type {DragDropManager} from '../manager/index.ts'; @@ -49,25 +49,9 @@ export class CollisionObserver< ); } - forceUpdateCount = signal(0); - - public forceUpdate(refresh = true) { + public forceUpdate() { untracked(() => { - const {source} = this.manager.dragOperation; - - batch(() => { - if (refresh) { - for (const droppable of this.manager.registry.droppables) { - if (source && !droppable.accepts(source)) { - continue; - } - - droppable.refreshShape(); - } - } - - this.#collisions.value = this.computeCollisions(); - }); + this.#collisions.value = this.computeCollisions(); }); } diff --git a/packages/abstract/src/core/entities/droppable/droppable.ts b/packages/abstract/src/core/entities/droppable/droppable.ts index 8b2a8983..b3cd6776 100644 --- a/packages/abstract/src/core/entities/droppable/droppable.ts +++ b/packages/abstract/src/core/entities/droppable/droppable.ts @@ -90,8 +90,4 @@ export class Droppable< public get isDropTarget() { return this.manager?.dragOperation.target?.id === this.id; } - - public refreshShape() { - // To be implemented by subclasses - } } diff --git a/packages/dom/src/core/entities/droppable/droppable.ts b/packages/dom/src/core/entities/droppable/droppable.ts index 1052e89b..c070c19d 100644 --- a/packages/dom/src/core/entities/droppable/droppable.ts +++ b/packages/dom/src/core/entities/droppable/droppable.ts @@ -5,14 +5,9 @@ import type { } from '@dnd-kit/abstract'; import {defaultCollisionDetection} from '@dnd-kit/collision'; import type {CollisionDetector} from '@dnd-kit/collision'; -import {Signal, reactive, signal, untracked} from '@dnd-kit/state'; +import {reactive, untracked} from '@dnd-kit/state'; import type {Shape} from '@dnd-kit/geometry'; -import { - DOMRectangle, - getDocument, - scheduler, - getFirstScrollableAncestor, -} from '@dnd-kit/dom/utilities'; +import {DOMRectangle, PositionObserver} from '@dnd-kit/dom/utilities'; import type {DragDropManager} from '../../manager/manager.ts'; @@ -33,6 +28,25 @@ export class Droppable extends AbstractDroppable< manager: DragDropManager | undefined ) { const {collisionDetector = defaultCollisionDetection} = input; + const updateShape = (boundingClientRect?: DOMRectReadOnly | null) => { + const {element} = this; + + if (!element || boundingClientRect === null) { + this.shape = undefined; + return undefined; + } + + const updatedShape = new DOMRectangle(element); + + const shape = untracked(() => this.shape); + if (updatedShape && shape?.equals(updatedShape)) { + return shape; + } + + this.shape = updatedShape; + + return updatedShape; + }; super( { @@ -45,74 +59,25 @@ export class Droppable extends AbstractDroppable< if (!manager) return; const {dragOperation} = manager; - - if (element && dragOperation.status.initialized) { - const scrollableAncestor = getFirstScrollableAncestor(element); - const doc = getDocument(element); - const root = - scrollableAncestor === doc.scrollingElement - ? doc - : scrollableAncestor; - const intersectionObserver = new IntersectionObserver( - (entries) => { - const [entry] = entries.slice(-1); - const {width, height} = entry.boundingClientRect; - - if (!width && !height) { - return; - } - - this.visible = entry.isIntersecting; - }, - { - root: root ?? doc, - rootMargin: '40%', - } - ); - - const mutationObserver = new MutationObserver(() => - scheduler.schedule(this.refreshShape) + const {source} = dragOperation; + const observePosition = + source && + dragOperation.status.initialized && + element && + this.accepts(source); + + if (observePosition) { + const positionObserver = new PositionObserver( + element, + updateShape ); - const resizeObserver = new ResizeObserver(() => - scheduler.schedule(this.refreshShape) - ); - - if (element.parentElement) { - mutationObserver.observe(element.parentElement, { - childList: true, - }); - } - - resizeObserver.observe(element); - intersectionObserver.observe(element); - return () => { + positionObserver.disconnect(); this.shape = undefined; - this.visible = undefined; - resizeObserver.disconnect(); - mutationObserver.disconnect(); - intersectionObserver.disconnect(); }; } }, - () => { - const {manager} = this; - - if (!manager) return; - - const {dragOperation} = manager; - const {status} = dragOperation; - const source = untracked(() => dragOperation.source); - - if (status.initialized) { - if (source?.type != null && !this.accepts(source)) { - return; - } - - scheduler.schedule(this.refreshShape); - } - }, () => { if (this.manager?.dragOperation.status.initialized) { return () => { @@ -125,54 +90,11 @@ export class Droppable extends AbstractDroppable< manager ); - this.internal = { - element: signal(element), - }; - this.refreshShape = this.refreshShape.bind(this); - - /* - * If a droppable target mounts during a drag operation, assume it is visible - * so that we can update its shape immediately. - */ - if (this.manager?.dragOperation.status.initialized) { - this.visible = true; - } + this.refreshShape = () => updateShape(); } @reactive - public accessor visible: Boolean | undefined; - - @reactive - public accessor placeholder: Element | undefined; - - public set element(value: Element | undefined) { - this.internal.element.value = value; - } - - public get element() { - return this.placeholder ?? this.internal?.element.value; - } - - public refreshShape(ignoreTransform?: boolean): Shape | undefined { - const {element, shape} = this; - - if (!element || this.visible === false) { - this.shape = undefined; - return undefined; - } - - const updatedShape = new DOMRectangle(element, ignoreTransform); - - if (updatedShape && shape?.equals(updatedShape)) { - return shape; - } - - this.shape = updatedShape; - - return updatedShape; - } + public accessor element: Element | undefined; - internal: { - element: Signal; - }; + public refreshShape: () => Shape | undefined; } diff --git a/packages/dom/src/core/plugins/feedback/Feedback.ts b/packages/dom/src/core/plugins/feedback/Feedback.ts index 8367814e..cd697a03 100644 --- a/packages/dom/src/core/plugins/feedback/Feedback.ts +++ b/packages/dom/src/core/plugins/feedback/Feedback.ts @@ -2,7 +2,7 @@ import {effect, untracked, type CleanupFunction} from '@dnd-kit/state'; import {Plugin} from '@dnd-kit/abstract'; import { animateTransform, - createPlaceholder, + cloneElement, DOMRectangle, isKeyboardEvent, showPopover, @@ -10,18 +10,21 @@ import { supportsPopover, supportsStyle, Styles, - type Transform, parseTranslate, + ProxiedElements, + type Transform, } from '@dnd-kit/dom/utilities'; import {Coordinates} from '@dnd-kit/geometry'; -import {DragDropManager} from '../../manager/index.ts'; +import type {DragDropManager} from '../../manager/index.ts'; +import {type Draggable} from '../../entities/index.ts'; const ATTR_PREFIX = 'data-dnd-'; const CSS_PREFIX = '--dnd-'; const ATTRIBUTE = `${ATTR_PREFIX}dragging`; const cssRules = `[${ATTRIBUTE}] {position: fixed !important;pointer-events: none !important;touch-action: none !important;z-index: calc(infinity);will-change: transform;top: var(${CSS_PREFIX}top, 0px) !important;left: var(${CSS_PREFIX}left, 0px) !important;width: var(${CSS_PREFIX}width, auto) !important;height: var(${CSS_PREFIX}height, auto) !important;box-sizing:border-box;}[${ATTRIBUTE}] *{pointer-events: none !important;}[${ATTRIBUTE}][style*="${CSS_PREFIX}translate"] {translate: var(${CSS_PREFIX}translate) !important;}[style*="${CSS_PREFIX}transition"] {transition: var(${CSS_PREFIX}transition) !important;}*:where([${ATTRIBUTE}][popover]){overflow:visible;background:var(${CSS_PREFIX}background);border:var(${CSS_PREFIX}border);margin:unset;padding:unset;color:inherit;}[${ATTRIBUTE}]::backdrop {display: none}`; const PLACEHOLDER_ATTRIBUTE = `${ATTR_PREFIX}placeholder`; +const IDENTIFIER_ATTRIBUTE = `${ATTR_PREFIX}id`; const IGNORED_ATTRIBUTES = [ATTRIBUTE, PLACEHOLDER_ATTRIBUTE, 'popover']; const IGNORED_STYLES = ['view-transition-name']; @@ -68,19 +71,17 @@ export class Feedback extends Plugin { return; } - const shape = new DOMRectangle(element, true); + let cleanup: CleanupFunction | undefined; + + const shape = new DOMRectangle(element, {ignoreTransforms: true}); const {width, height, top, left} = shape; const styles = new Styles(element); const {background, border, transition, translate} = getComputedStyles(element); - const droppable = manager.registry.droppables.get(source.id); const clone = feedback === 'clone'; + const placeholder = - feedback !== 'move' - ? createPlaceholder(element, clone, { - [PLACEHOLDER_ATTRIBUTE]: '', - }) - : null; + feedback !== 'move' ? createPlaceholder(source) : null; const isKeyboardOperation = untracked(() => isKeyboardEvent(manager.dragOperation.activatorEvent) ); @@ -138,7 +139,9 @@ export class Feedback extends Plugin { CSS_PREFIX ); - if (placeholder) element.insertAdjacentElement('afterend', placeholder); + if (placeholder) { + element.insertAdjacentElement('afterend', placeholder); + } if (supportsPopover(element)) { if (!element.hasAttribute('popover')) { @@ -147,7 +150,7 @@ export class Feedback extends Plugin { showPopover(element); } - const actual = new DOMRectangle(element, true); + const actual = new DOMRectangle(element, {ignoreTransforms: true}); const offset = { top: projected.top - actual.top, left: projected.left - actual.left, @@ -170,7 +173,9 @@ export class Feedback extends Plugin { const resizeObserver = new ResizeObserver(() => { if (!placeholder) return; - const placeholderShape = new DOMRectangle(placeholder, true); + const placeholderShape = new DOMRectangle(placeholder, { + ignoreTransforms: true, + }); const origin = transformOrigin ?? {x: 1, y: 1}; const dX = (width - placeholderShape.width) * origin.x + delta.x; const dY = (height - placeholderShape.height) * origin.y + delta.y; @@ -203,19 +208,6 @@ export class Feedback extends Plugin { manager.dragOperation.shape = new DOMRectangle(element); }); - if (droppable && placeholder) { - if (untracked(() => droppable.element) === element) { - /* - * If there is a droppable with the same id and element as the draggable source - * set the placeholder as the droppable's placeholder, which takes - * precedence over the dorppable's `element` property when computing - * its shape. - */ - droppable.placeholder = placeholder; - untracked(droppable.refreshShape); - } - } - /* Initialize drag operation shape */ dragOperation.shape = new DOMRectangle(element); source.status = 'dragging'; @@ -352,7 +344,7 @@ export class Feedback extends Plugin { } }; - let cleanup: CleanupFunction | undefined = () => { + cleanup = () => { elementMutationObserver?.disconnect(); documentMutationObserver?.disconnect(); resizeObserver.disconnect(); @@ -373,12 +365,7 @@ export class Feedback extends Plugin { cleanupEffect(); dropEffectCleanup(); - if (droppable) { - droppable.placeholder = undefined; - } - source.status = 'idle'; - moved = false; }; @@ -402,16 +389,28 @@ export class Feedback extends Plugin { const target = placeholder ?? element; - styles.remove(['translate'], CSS_PREFIX); + const animations = element.getAnimations(); + + if (animations.length) { + animations.forEach((animation) => { + const {effect} = animation; + + if (effect instanceof KeyframeEffect) { + if ( + effect.getKeyframes().some((keyframe) => keyframe.translate) + ) { + animation.finish(); + } + } + }); + } const final = new DOMRectangle(target); - const current = new DOMRectangle(element, true).translate( - transform.x, - transform.y - ); + const current = new DOMRectangle(element); + const delta = { - x: current.left - final.left, - y: current.top - final.top, + x: current.center.x - final.center.x, + y: current.center.y - final.center.y, }; const finalTransform = { x: transform.x - delta.x, @@ -447,6 +446,9 @@ export class Feedback extends Plugin { duration: moved ? 250 : 0, easing: 'ease', }, + onReady() { + styles.remove(['translate'], CSS_PREFIX); + }, onFinish() { requestAnimationFrame(restoreFocus); onComplete?.(); @@ -466,3 +468,88 @@ export class Feedback extends Plugin { }; } } + +function createPlaceholder(source: Draggable) { + const {element, manager} = source; + + if (!element || !manager) return; + + return untracked(() => { + const {droppables} = manager.registry; + const containedDroppables = []; + + for (const droppable of droppables) { + if (!droppable.element) continue; + + if ( + element === droppable.element || + element.contains(droppable.element) + ) { + droppable.element.setAttribute( + IDENTIFIER_ATTRIBUTE, + `${JSON.stringify(droppable.id)}` + ); + containedDroppables.push(droppable); + } + } + + const cleanup: CleanupFunction[] = []; + const placeholder = cloneElement(element); + const {remove} = placeholder; + + for (const droppable of containedDroppables) { + if (!droppable.element) continue; + + const selector = `[${IDENTIFIER_ATTRIBUTE}='${JSON.stringify(droppable.id)}']`; + const clonedElement = placeholder.matches(selector) + ? placeholder + : placeholder.querySelector(selector); + + droppable.element?.removeAttribute(IDENTIFIER_ATTRIBUTE); + + if (!clonedElement) continue; + + let current = droppable.element; + + droppable.element = clonedElement; + clonedElement.removeAttribute(IDENTIFIER_ATTRIBUTE); + + ProxiedElements.set(current, clonedElement); + + const proxy = Proxy.revocable(droppable, { + set(target, key, newValue) { + if (key === 'element') { + ProxiedElements.delete(current); + + if (newValue instanceof Element) { + ProxiedElements.set(newValue, clonedElement); + } + + current = newValue; + + return false; + } + + return Reflect.set(target, key, newValue); + }, + }); + + cleanup.push(() => { + proxy.revoke(); + ProxiedElements.delete(current); + droppable.element = current; + }); + } + + placeholder.setAttribute('inert', 'true'); + placeholder.setAttribute('tab-index', '-1'); + placeholder.setAttribute('aria-hidden', 'true'); + placeholder.setAttribute(PLACEHOLDER_ATTRIBUTE, ''); + placeholder.remove = () => { + cleanup.forEach((fn) => fn()); + remove.call(placeholder); + }; + + return placeholder; + }); +} diff --git a/packages/dom/src/sortable/SortableKeyboardPlugin.ts b/packages/dom/src/sortable/SortableKeyboardPlugin.ts index 1f501f70..165ac8ae 100644 --- a/packages/dom/src/sortable/SortableKeyboardPlugin.ts +++ b/packages/dom/src/sortable/SortableKeyboardPlugin.ts @@ -127,35 +127,37 @@ export class SortableKeyboardPlugin extends Plugin { const {id} = firstCollision; actions.setDropTarget(id).then(() => { - const {source} = dragOperation; + // Wait until optimistic sorting has a chance to update the DOM + queueMicrotask(() => { + const {source} = dragOperation; - if (!source || !isSortable(source)) { - return; - } - - const {element} = source.sortable; + if (!source || !isSortable(source)) { + return; + } - if (!element) return; + const {element} = source.sortable; - scrollIntoViewIfNeeded(element); + if (!element) return; - scheduler.schedule(() => { - const shape = new DOMRectangle(element); + scrollIntoViewIfNeeded(element); + scheduler.schedule(() => { + const shape = new DOMRectangle(element); - if (!shape) { - return; - } - - actions.move({ - to: { - x: shape.center.x, - y: shape.center.y, - }, - }); + if (!shape) { + return; + } - actions.setDropTarget(source.id).then(() => { - dragOperation.shape = shape; - collisionObserver.enable(); + actions.move({ + to: { + x: shape.center.x, + y: shape.center.y, + }, + }); + + actions.setDropTarget(source.id).then(() => { + dragOperation.shape = shape; + collisionObserver.enable(); + }); }); }); }); diff --git a/packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts b/packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts new file mode 100644 index 00000000..e6d0530f --- /dev/null +++ b/packages/dom/src/utilities/bounding-rectangle/PositionObserver.ts @@ -0,0 +1,198 @@ +import {getFirstScrollableAncestor} from '../scroll/getScrollableAncestors.ts'; +import {isRectEqual} from './isRectEqual.ts'; +import {Listeners} from '../event-listeners/index.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, +]; + +type PositionObserverCallback = ( + boundingClientRect: DOMRectReadOnly | null +) => void; + +export class PositionObserver { + constructor( + private element: Element, + callback: PositionObserverCallback, + options: {debug?: boolean} = {debug: true} + ) { + this.#callback = callback; + this.boundingClientRect = element.getBoundingClientRect(); + + 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); + } + + const doc = element.ownerDocument ?? document; + const scrollableAncestor = getFirstScrollableAncestor(element); + + this.#visibilityObserver = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => { + const entry = entries[entries.length - 1]; + const {isIntersecting: visible} = entry; + + if (visible) { + this.#partialVisibilityObserver.observe(element); + this.#resizeObserver.observe(element); + this.#observePosition(); + + if (scrollableAncestor) { + this.#listeners.bind(scrollableAncestor, { + type: 'scroll', + listener: this.#observePosition, + options: {passive: true}, + }); + } + } else { + this.#positionObserver?.disconnect(); + this.#resizeObserver.disconnect(); + this.#partialVisibilityObserver?.disconnect(); + this.#callback(null); + this.#listeners.clear(); + + if (this.#debug) this.#debug.style.visibility = 'hidden'; + } + }, + { + root: + scrollableAncestor === doc.scrollingElement + ? doc + : scrollableAncestor, + rootMargin: '40%', + } + ); + + this.#partialVisibilityObserver = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => { + const entry = entries[entries.length - 1]; + const {boundingClientRect, intersectionRect, intersectionRatio} = entry; + const {width, height} = boundingClientRect; + + 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.#observePosition(); + }, + { + threshold: THRESHOLD, + root: element.ownerDocument ?? document, + } + ); + + this.#resizeObserver = new ResizeObserver(this.#observePosition); + + this.#visibilityObserver.observe(element); + } + + public boundingClientRect: DOMRectReadOnly; + + public disconnect() { + this.#resizeObserver.disconnect(); + this.#positionObserver?.disconnect(); + this.#visibilityObserver.disconnect(); + this.#partialVisibilityObserver.disconnect(); + this.#debug?.remove(); + this.#listeners.clear(); + } + + #listeners = new Listeners(); + #callback: PositionObserverCallback; + #offsetTop = 0; + #offsetLeft = 0; + #visibleRect: DOMRectReadOnly | undefined; + #previousBoundingClientRect: DOMRectReadOnly | undefined; + #resizeObserver: ResizeObserver; + #positionObserver: IntersectionObserver | undefined; + #visibilityObserver: IntersectionObserver; + #partialVisibilityObserver: IntersectionObserver; + #debug: HTMLElement | undefined; + + #observePosition = () => { + const {element} = this; + + if (!element.isConnected) { + this.disconnect(); + 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(); + 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) + ) { + this.#observePosition(); + } + }, + { + threshold: [0, 1], + rootMargin, + root, + } + ); + + this.#positionObserver.observe(element); + this.#notify(); + }; + + async #notify() { + if ( + !isRectEqual(this.boundingClientRect, this.#previousBoundingClientRect) + ) { + this.#updateDebug(); + this.#callback(this.boundingClientRect); + this.#previousBoundingClientRect = this.boundingClientRect; + } + } + + #updateDebug() { + if (this.#debug) { + const {top, left, width, height} = this.boundingClientRect; + + 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`; + } + } +} diff --git a/packages/dom/src/utilities/bounding-rectangle/index.ts b/packages/dom/src/utilities/bounding-rectangle/index.ts index c7e6c640..78a57640 100644 --- a/packages/dom/src/utilities/bounding-rectangle/index.ts +++ b/packages/dom/src/utilities/bounding-rectangle/index.ts @@ -1,2 +1,3 @@ export {getBoundingRectangle} from './getBoundingRectangle.ts'; export {getViewportBoundingRectangle} from './getViewportBoundingRectangle.ts'; +export {PositionObserver} from './PositionObserver.ts'; diff --git a/packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts b/packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts new file mode 100644 index 00000000..742bc114 --- /dev/null +++ b/packages/dom/src/utilities/bounding-rectangle/isRectEqual.ts @@ -0,0 +1,13 @@ +type Rect = Pick; + +export function isRectEqual(a: Rect | undefined, b: Rect | undefined) { + if (a === b) return true; + if (!a || !b) return false; + + return ( + a.top == b.top && + a.left == b.left && + a.right == b.right && + a.bottom == b.bottom + ); +} diff --git a/packages/dom/src/utilities/element/createPlaceholder.ts b/packages/dom/src/utilities/element/createPlaceholder.ts deleted file mode 100644 index de4ff777..00000000 --- a/packages/dom/src/utilities/element/createPlaceholder.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {cloneElement} from './cloneElement.ts'; -import {supportsStyle} from '../type-guards/supportsStyle.ts'; - -export function createPlaceholder( - element: Element, - clone = false, - attributes?: Record -): Element { - const placeholder = cloneElement(element); - - if (supportsStyle(placeholder)) { - if (!clone) { - placeholder.style.setProperty('opacity', '0'); - } - } - - placeholder.setAttribute('inert', 'true'); - placeholder.setAttribute('tab-index', '-1'); - placeholder.setAttribute('aria-hidden', 'true'); - - if (attributes) { - for (const [key, value] of Object.entries(attributes)) { - placeholder.setAttribute(key, value); - } - } - - return placeholder; -} diff --git a/packages/dom/src/utilities/index.ts b/packages/dom/src/utilities/index.ts index bd51a10f..c2fd8b96 100644 --- a/packages/dom/src/utilities/index.ts +++ b/packages/dom/src/utilities/index.ts @@ -6,11 +6,7 @@ export { export {canUseDOM, getDocument, getWindow} from './execution-context/index.ts'; -export { - cloneElement, - createPlaceholder, - ProxiedElements, -} from './element/index.ts'; +export {cloneElement, ProxiedElements} from './element/index.ts'; export {Listeners} from './event-listeners/index.ts'; diff --git a/packages/dom/src/utilities/keyframes/getFinalKeyframe.ts b/packages/dom/src/utilities/keyframes/getFinalKeyframe.ts deleted file mode 100644 index 100c9d65..00000000 --- a/packages/dom/src/utilities/keyframes/getFinalKeyframe.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function getFinalKeyframe(element: Element): Keyframe | null { - const animations = element.getAnimations(); - - if (animations.length > 0) { - const [animation] = animations; - const {effect} = animation; - const keyframes = - effect instanceof KeyframeEffect ? effect.getKeyframes() : []; - - if (keyframes.length > 0) { - return keyframes?.[keyframes.length - 1]; - } - } - - return null; -} diff --git a/packages/dom/src/utilities/scroll/scrollIntoViewIfNeeded.ts b/packages/dom/src/utilities/scroll/scrollIntoViewIfNeeded.ts index ca9e607e..1048efbf 100644 --- a/packages/dom/src/utilities/scroll/scrollIntoViewIfNeeded.ts +++ b/packages/dom/src/utilities/scroll/scrollIntoViewIfNeeded.ts @@ -1,5 +1,5 @@ import {getComputedStyles} from '../styles/getComputedStyles.ts'; -import {getScrollableAncestors} from './getScrollableAncestors.ts'; +import {getFirstScrollableAncestor} from './getScrollableAncestors.ts'; function supportsScrollIntoViewIfNeeded( element: Element @@ -22,7 +22,7 @@ export function scrollIntoViewIfNeeded(el: Element, centerIfNeeded = false) { return el.scrollIntoView(); } - var [parent] = getScrollableAncestors(el, {limit: 1}); + var parent = getFirstScrollableAncestor(el); if (!(parent instanceof HTMLElement)) { return; diff --git a/packages/dom/src/utilities/shapes/DOMRectangle.ts b/packages/dom/src/utilities/shapes/DOMRectangle.ts index 26b40498..7ac64b0d 100644 --- a/packages/dom/src/utilities/shapes/DOMRectangle.ts +++ b/packages/dom/src/utilities/shapes/DOMRectangle.ts @@ -1,21 +1,28 @@ import {Rectangle} from '@dnd-kit/geometry'; import {inverseTransform} from '../transform/inverseTransform.ts'; -import {getBoundingRectangle} from '../bounding-rectangle/getBoundingRectangle.ts'; import {getComputedStyles} from '../styles/getComputedStyles.ts'; import {parseTransform, type Transform} from '../transform/index.ts'; -import {getFinalKeyframe} from '../keyframes/getFinalKeyframe.ts'; + +interface Options { + ignoreTransforms?: boolean; +} export class DOMRectangle extends Rectangle { - constructor(element: Element, ignoreTransforms = false) { + constructor(element: Element, options: Options = {}) { + const {ignoreTransforms = false} = options; + const resetAnimations = forceFinishAnimations(element); let {top, left, right, bottom, width, height} = - getBoundingRectangle(element); + element.getBoundingClientRect(); const computedStyles = getComputedStyles(element); const parsedTransform = parseTransform(computedStyles); const scale = { x: parsedTransform?.scaleX ?? 1, y: parsedTransform?.scaleY ?? 1, }; + + resetAnimations?.(); + const projectedTransform = getProjectedTransform(element); if (parsedTransform && (ignoreTransforms || projectedTransform)) { @@ -50,19 +57,89 @@ export class DOMRectangle extends Rectangle { * Get the projected transform of an element based on its final keyframe */ function getProjectedTransform(element: Element): Transform | null { - const keyframe = getFinalKeyframe(element); + const animations = element.getAnimations(); + let projectedTransform: Transform | null = null; + + if (!animations.length) return null; + + for (const animation of animations) { + const keyframes = + animation.effect instanceof KeyframeEffect + ? animation.effect.getKeyframes() + : []; + const keyframe = keyframes[keyframes.length - 1]; + + if (!keyframe) continue; - if (keyframe) { const {transform = '', translate = '', scale = ''} = keyframe; if (transform || translate || scale) { - return parseTransform({ + const parsedTransform = parseTransform({ transform: typeof transform === 'string' ? transform : '', translate: typeof translate === 'string' ? translate : '', scale: typeof scale === 'string' ? scale : '', }); + + if (parsedTransform) { + projectedTransform = projectedTransform + ? { + x: projectedTransform.x + parsedTransform.x, + y: projectedTransform.y + parsedTransform.y, + z: projectedTransform.z ?? parsedTransform.z, + scaleX: projectedTransform.scaleX * parsedTransform.scaleX, + scaleY: projectedTransform.scaleY * parsedTransform.scaleY, + } + : parsedTransform; + } } } - return null; + return projectedTransform; +} + +/* + * Force animations on ancestors of the element into their end state + * and return a function to reset them back to their current state. + * + * This is useful as it allows us to immediately calculate the final position + * of an element without having to wait for the animations to finish. + */ +function forceFinishAnimations(element: Element): (() => void) | undefined { + const animations = element.ownerDocument + .getAnimations() + .filter((animation) => { + if (animation.effect instanceof KeyframeEffect) { + const {target} = animation.effect; + + if (target !== element && target?.contains(element)) { + return animation.effect.getKeyframes().some((keyframe) => { + const {transform, translate, scale, width, height} = keyframe; + + return transform || translate || scale || width || height; + }); + } + } + }) + .map((animation) => { + const {effect, currentTime} = animation; + const duration = effect?.getComputedTiming().duration; + + if (animation.pending) return; + + if ( + typeof duration == 'number' && + typeof currentTime == 'number' && + currentTime < duration + ) { + animation.currentTime = duration; + + return () => { + animation.currentTime = currentTime; + }; + } + }); + + if (animations.length > 0) { + return () => animations.forEach((reset) => reset?.()); + } } diff --git a/packages/dom/src/utilities/transform/computeTranslate.ts b/packages/dom/src/utilities/transform/computeTranslate.ts index 660522d8..7cba2656 100644 --- a/packages/dom/src/utilities/transform/computeTranslate.ts +++ b/packages/dom/src/utilities/transform/computeTranslate.ts @@ -1,13 +1,37 @@ -import {getFinalKeyframe} from '../keyframes/getFinalKeyframe.ts'; import {getComputedStyles} from '../styles/getComputedStyles.ts'; import {parseTranslate} from './parseTranslate.ts'; +function getFinalKeyframe( + element: Element, + match: (keyframe: Keyframe) => boolean +): Keyframe | null { + const animations = element.getAnimations(); + + if (animations.length > 0) { + for (const animation of animations) { + const {effect} = animation; + const keyframes = + effect instanceof KeyframeEffect ? effect.getKeyframes() : []; + const matchedKeyframes = keyframes.filter(match); + + if (matchedKeyframes.length > 0) { + return matchedKeyframes[matchedKeyframes.length - 1]; + } + } + } + + return null; +} + export function computeTranslate(element: Element): { x: number; y: number; z: number; } { - const keyframe = getFinalKeyframe(element); + const keyframe = getFinalKeyframe( + element, + (keyframe) => 'translate' in keyframe + ); if (keyframe) { const {translate = ''} = keyframe; diff --git a/packages/react/src/core/context/renderer.ts b/packages/react/src/core/context/renderer.ts index 78b86c75..9942c9c2 100644 --- a/packages/react/src/core/context/renderer.ts +++ b/packages/react/src/core/context/renderer.ts @@ -1,4 +1,4 @@ -import {useTransition, useState, useRef} from 'react'; +import {useTransition, useState, useRef, useLayoutEffect} from 'react'; import type {Renderer} from '@dnd-kit/abstract'; import {useConstant, useOnValueChange} from '@dnd-kit/react/hooks'; @@ -13,10 +13,14 @@ export function useRenderer() { }, })); - useOnValueChange(transitionCount, () => { - resolver.current?.(); - rendering.current = undefined; - }); + useOnValueChange( + transitionCount, + () => { + resolver.current?.(); + rendering.current = undefined; + }, + useLayoutEffect + ); return { renderer,