From ebab924601c3d93933aed2698b4db96b4cafa3d4 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 24 Apr 2024 08:51:40 +0200 Subject: [PATCH] refactor(cdk/drag-drop): move preview-related logic into a separate class Moves the logic for creating and managing the preview into a separate class to make it a bit easier to manage. This new class is internal only. --- src/cdk/drag-drop/dom/root-node.ts | 25 +++++ src/cdk/drag-drop/dom/styling.ts | 22 ++++ src/cdk/drag-drop/drag-ref.ts | 151 +++++----------------------- src/cdk/drag-drop/preview-ref.ts | 156 +++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 128 deletions(-) create mode 100644 src/cdk/drag-drop/dom/root-node.ts create mode 100644 src/cdk/drag-drop/preview-ref.ts diff --git a/src/cdk/drag-drop/dom/root-node.ts b/src/cdk/drag-drop/dom/root-node.ts new file mode 100644 index 000000000000..896f01dac7a0 --- /dev/null +++ b/src/cdk/drag-drop/dom/root-node.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {EmbeddedViewRef} from '@angular/core'; + +/** + * Gets the root HTML element of an embedded view. + * If the root is not an HTML element it gets wrapped in one. + */ +export function getRootNode(viewRef: EmbeddedViewRef, _document: Document): HTMLElement { + const rootNodes: Node[] = viewRef.rootNodes; + + if (rootNodes.length === 1 && rootNodes[0].nodeType === _document.ELEMENT_NODE) { + return rootNodes[0] as HTMLElement; + } + + const wrapper = _document.createElement('div'); + rootNodes.forEach(node => wrapper.appendChild(node)); + return wrapper; +} diff --git a/src/cdk/drag-drop/dom/styling.ts b/src/cdk/drag-drop/dom/styling.ts index e6c05e8525b1..907afdeaebbc 100644 --- a/src/cdk/drag-drop/dom/styling.ts +++ b/src/cdk/drag-drop/dom/styling.ts @@ -94,3 +94,25 @@ export function combineTransforms(transform: string, initialTransform?: string): ? transform + ' ' + initialTransform : transform; } + +/** + * Matches the target element's size to the source's size. + * @param target Element that needs to be resized. + * @param sourceRect Dimensions of the source element. + */ +export function matchElementSize(target: HTMLElement, sourceRect: DOMRect): void { + target.style.width = `${sourceRect.width}px`; + target.style.height = `${sourceRect.height}px`; + target.style.transform = getTransform(sourceRect.left, sourceRect.top); +} + +/** + * Gets a 3d `transform` that can be applied to an element. + * @param x Desired position of the element along the X axis. + * @param y Desired position of the element along the Y axis. + */ +export function getTransform(x: number, y: number): string { + // Round the transforms since some browsers will + // blur the elements for sub-pixel transforms. + return `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`; +} diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 8cdad4161a78..860f52993983 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -22,14 +22,15 @@ import {DragDropRegistry} from './drag-drop-registry'; import { combineTransforms, DragCSSStyleDeclaration, - extendStyles, + getTransform, toggleNativeDragInteractions, toggleVisibility, } from './dom/styling'; -import {getTransformTransitionDurationInMs} from './dom/transition-duration'; import {getMutableClientRect, adjustDomRect} from './dom/dom-rect'; import {ParentPositionTracker} from './dom/parent-position-tracker'; import {deepCloneNode} from './dom/clone-node'; +import {DragPreviewTemplate, PreviewRef} from './preview-ref'; +import {getRootNode} from './dom/root-node'; /** Object that can be used to configure the behavior of DragRef. */ export interface DragRefConfig { @@ -82,11 +83,6 @@ interface DragHelperTemplate { context: T; } -/** Template that can be used to create a drag preview element. */ -interface DragPreviewTemplate extends DragHelperTemplate { - matchSize?: boolean; -} - /** Point on the page or within an element. */ export interface Point { x: number; @@ -118,10 +114,7 @@ export type PreviewContainer = 'global' | 'parent' | ElementRef | H */ export class DragRef { /** Element displayed next to the user's pointer while the element is dragged. */ - private _preview: HTMLElement; - - /** Reference to the view of the preview element. */ - private _previewRef: EmbeddedViewRef | null; + private _preview: PreviewRef | null; /** Container into which to insert the preview. */ private _previewContainer: PreviewContainer | undefined; @@ -627,9 +620,8 @@ export class DragRef { /** Destroys the preview element and its ViewRef. */ private _destroyPreview() { - this._preview?.remove(); - this._previewRef?.destroy(); - this._preview = this._previewRef = null!; + this._preview?.destroy(); + this._preview = null; } /** Destroys the placeholder element and its ViewRef. */ @@ -834,14 +826,24 @@ export class DragRef { // Create the preview after the initial transform has // been cached, because it can be affected by the transform. - this._preview = this._createPreviewElement(); + this._preview = new PreviewRef( + this._document, + this._rootElement, + this._direction, + this._initialDomRect!, + this._previewTemplate || null, + this.previewClass || null, + this._pickupPositionOnPage, + this._initialTransform, + this._config.zIndex || 1000, + ); + this._preview.attach(this._getPreviewInsertionPoint(parent, shadowRoot)); // We move the element out at the end of the body and we make it hidden, because keeping it in // place will throw off the consumer's `:last-child` selectors. We can't remove the element // from the DOM completely, because iOS will stop firing all subsequent events in the chain. toggleVisibility(element, false, dragImportantProperties); this._document.body.appendChild(parent.replaceChild(placeholder, element)); - this._getPreviewInsertionPoint(parent, shadowRoot).appendChild(this._preview); this.started.next({source: this, event}); // Emit before notifying the container. dropContainer.start(); this._initialContainer = dropContainer; @@ -1056,75 +1058,6 @@ export class DragRef { } } - /** - * Creates the element that will be rendered next to the user's pointer - * and will be used as a preview of the element that is being dragged. - */ - private _createPreviewElement(): HTMLElement { - const previewConfig = this._previewTemplate; - const previewClass = this.previewClass; - const previewTemplate = previewConfig ? previewConfig.template : null; - let preview: HTMLElement; - - if (previewTemplate && previewConfig) { - // Measure the element before we've inserted the preview - // since the insertion could throw off the measurement. - const rootRect = previewConfig.matchSize ? this._initialDomRect : null; - const viewRef = previewConfig.viewContainer.createEmbeddedView( - previewTemplate, - previewConfig.context, - ); - viewRef.detectChanges(); - preview = getRootNode(viewRef, this._document); - this._previewRef = viewRef; - if (previewConfig.matchSize) { - matchElementSize(preview, rootRect!); - } else { - preview.style.transform = getTransform( - this._pickupPositionOnPage.x, - this._pickupPositionOnPage.y, - ); - } - } else { - preview = deepCloneNode(this._rootElement); - matchElementSize(preview, this._initialDomRect!); - - if (this._initialTransform) { - preview.style.transform = this._initialTransform; - } - } - - extendStyles( - preview.style, - { - // It's important that we disable the pointer events on the preview, because - // it can throw off the `document.elementFromPoint` calls in the `CdkDropList`. - 'pointer-events': 'none', - // We have to reset the margin, because it can throw off positioning relative to the viewport. - 'margin': '0', - 'position': 'fixed', - 'top': '0', - 'left': '0', - 'z-index': `${this._config.zIndex || 1000}`, - }, - dragImportantProperties, - ); - - toggleNativeDragInteractions(preview, false); - preview.classList.add('cdk-drag-preview'); - preview.setAttribute('dir', this._direction); - - if (previewClass) { - if (Array.isArray(previewClass)) { - previewClass.forEach(className => preview.classList.add(className)); - } else { - preview.classList.add(previewClass); - } - } - - return preview; - } - /** * Animates the preview element from its current position to the location of the drop placeholder. * @returns Promise that resolves when the animation completes. @@ -1138,7 +1071,7 @@ export class DragRef { const placeholderRect = this._placeholder.getBoundingClientRect(); // Apply the class that adds a transition to the preview. - this._preview.classList.add('cdk-drag-animating'); + this._preview!.addClass('cdk-drag-animating'); // Move the preview to the placeholder position. this._applyPreviewTransform(placeholderRect.left, placeholderRect.top); @@ -1147,7 +1080,7 @@ export class DragRef { // we need to trigger a style recalculation in order for the `cdk-drag-animating` class to // apply its style, we take advantage of the available info to figure out whether we need to // bind the event in the first place. - const duration = getTransformTransitionDurationInMs(this._preview); + const duration = this._preview!.getTransitionDuration(); if (duration === 0) { return Promise.resolve(); @@ -1170,7 +1103,7 @@ export class DragRef { // Since we know how long it's supposed to take, add a timeout with a 50% buffer that'll // fire if the transition hasn't completed when it was supposed to. const timeout = setTimeout(handler as Function, duration * 1.5); - this._preview.addEventListener('transitionend', handler); + this._preview!.addEventListener('transitionend', handler); }); }); } @@ -1373,7 +1306,7 @@ export class DragRef { // it could be completely different and the transform might not make sense anymore. const initialTransform = this._previewTemplate?.template ? undefined : this._initialTransform; const transform = getTransform(x, y); - this._preview.style.transform = combineTransforms(transform, initialTransform); + this._preview!.setTransform(combineTransforms(transform, initialTransform)); } /** @@ -1559,7 +1492,7 @@ export class DragRef { // we cached it too early before the element dimensions were computed. if (!this._previewRect || (!this._previewRect.width && !this._previewRect.height)) { this._previewRect = this._preview - ? this._preview.getBoundingClientRect() + ? this._preview!.getBoundingClientRect() : this._initialDomRect!; } @@ -1589,17 +1522,6 @@ export class DragRef { } } -/** - * Gets a 3d `transform` that can be applied to an element. - * @param x Desired position of the element along the X axis. - * @param y Desired position of the element along the Y axis. - */ -function getTransform(x: number, y: number): string { - // Round the transforms since some browsers will - // blur the elements for sub-pixel transforms. - return `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`; -} - /** Clamps a value between a minimum and a maximum. */ function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); @@ -1613,33 +1535,6 @@ function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { return event.type[0] === 't'; } -/** - * Gets the root HTML element of an embedded view. - * If the root is not an HTML element it gets wrapped in one. - */ -function getRootNode(viewRef: EmbeddedViewRef, _document: Document): HTMLElement { - const rootNodes: Node[] = viewRef.rootNodes; - - if (rootNodes.length === 1 && rootNodes[0].nodeType === _document.ELEMENT_NODE) { - return rootNodes[0] as HTMLElement; - } - - const wrapper = _document.createElement('div'); - rootNodes.forEach(node => wrapper.appendChild(node)); - return wrapper; -} - -/** - * Matches the target element's size to the source's size. - * @param target Element that needs to be resized. - * @param sourceRect Dimensions of the source element. - */ -function matchElementSize(target: HTMLElement, sourceRect: DOMRect): void { - target.style.width = `${sourceRect.width}px`; - target.style.height = `${sourceRect.height}px`; - target.style.transform = getTransform(sourceRect.left, sourceRect.top); -} - /** Callback invoked for `selectstart` events inside the shadow DOM. */ function shadowDomSelectStart(event: Event) { event.preventDefault(); diff --git a/src/cdk/drag-drop/preview-ref.ts b/src/cdk/drag-drop/preview-ref.ts new file mode 100644 index 000000000000..45d81a653971 --- /dev/null +++ b/src/cdk/drag-drop/preview-ref.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {EmbeddedViewRef, TemplateRef, ViewContainerRef} from '@angular/core'; +import {Direction} from '@angular/cdk/bidi'; +import { + extendStyles, + getTransform, + matchElementSize, + toggleNativeDragInteractions, +} from './dom/styling'; +import {deepCloneNode} from './dom/clone-node'; +import {getRootNode} from './dom/root-node'; +import {getTransformTransitionDurationInMs} from './dom/transition-duration'; + +/** Template that can be used to create a drag preview element. */ +export interface DragPreviewTemplate { + matchSize?: boolean; + template: TemplateRef | null; + viewContainer: ViewContainerRef; + context: T; +} + +/** Inline styles to be set as `!important` while dragging. */ +const importantProperties = new Set([ + // Needs to be important, because some `mat-table` sets `position: sticky !important`. See #22781. + 'position', +]); + +export class PreviewRef { + /** Reference to the view of the preview element. */ + private _previewEmbeddedView: EmbeddedViewRef | null; + + /** Reference to the preview element. */ + private _preview: HTMLElement; + + constructor( + private _document: Document, + private _rootElement: HTMLElement, + private _direction: Direction, + private _initialDomRect: DOMRect, + private _previewTemplate: DragPreviewTemplate | null, + private _previewClass: string | string[] | null, + private _pickupPositionOnPage: { + x: number; + y: number; + }, + private _initialTransform: string | null, + private _zIndex: number, + ) {} + + attach(parent: HTMLElement): void { + this._preview = this._createPreview(); + parent.appendChild(this._preview); + } + + destroy(): void { + this._preview?.remove(); + this._previewEmbeddedView?.destroy(); + this._preview = this._previewEmbeddedView = null!; + } + + setTransform(value: string): void { + this._preview.style.transform = value; + } + + getBoundingClientRect(): DOMRect { + return this._preview.getBoundingClientRect(); + } + + addClass(className: string): void { + this._preview.classList.add(className); + } + + getTransitionDuration(): number { + return getTransformTransitionDurationInMs(this._preview); + } + + addEventListener(name: string, handler: EventListenerOrEventListenerObject) { + this._preview.addEventListener(name, handler); + } + + removeEventListener(name: string, handler: EventListenerOrEventListenerObject) { + this._preview.removeEventListener(name, handler); + } + + private _createPreview(): HTMLElement { + const previewConfig = this._previewTemplate; + const previewClass = this._previewClass; + const previewTemplate = previewConfig ? previewConfig.template : null; + let preview: HTMLElement; + + if (previewTemplate && previewConfig) { + // Measure the element before we've inserted the preview + // since the insertion could throw off the measurement. + const rootRect = previewConfig.matchSize ? this._initialDomRect : null; + const viewRef = previewConfig.viewContainer.createEmbeddedView( + previewTemplate, + previewConfig.context, + ); + viewRef.detectChanges(); + preview = getRootNode(viewRef, this._document); + this._previewEmbeddedView = viewRef; + if (previewConfig.matchSize) { + matchElementSize(preview, rootRect!); + } else { + preview.style.transform = getTransform( + this._pickupPositionOnPage.x, + this._pickupPositionOnPage.y, + ); + } + } else { + preview = deepCloneNode(this._rootElement); + matchElementSize(preview, this._initialDomRect!); + + if (this._initialTransform) { + preview.style.transform = this._initialTransform; + } + } + + extendStyles( + preview.style, + { + // It's important that we disable the pointer events on the preview, because + // it can throw off the `document.elementFromPoint` calls in the `CdkDropList`. + 'pointer-events': 'none', + // We have to reset the margin, because it can throw off positioning relative to the viewport. + 'margin': '0', + 'position': 'absolute', + 'top': '0', + 'left': '0', + 'z-index': `${this._zIndex}`, + }, + importantProperties, + ); + + toggleNativeDragInteractions(preview, false); + preview.classList.add('cdk-drag-preview'); + preview.setAttribute('dir', this._direction); + + if (previewClass) { + if (Array.isArray(previewClass)) { + previewClass.forEach(className => preview.classList.add(className)); + } else { + preview.classList.add(previewClass); + } + } + + return preview; + } +}