UNPKG

@angular/cdk

Version:

Angular Material Component Development Kit

982 lines (981 loc) 189 kB
/** * @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 { normalizePassiveListenerOptions, _getEventTarget, _getShadowRoot, } from '@angular/cdk/platform'; import { coerceBooleanProperty, coerceElement } from '@angular/cdk/coercion'; import { isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader } from '@angular/cdk/a11y'; import { Subscription, Subject } from 'rxjs'; import { combineTransforms, extendStyles, toggleNativeDragInteractions, toggleVisibility, } from './dom/styling'; import { getTransformTransitionDurationInMs } from './dom/transition-duration'; import { getMutableClientRect, adjustClientRect } from './dom/client-rect'; import { ParentPositionTracker } from './dom/parent-position-tracker'; import { deepCloneNode } from './dom/clone-node'; /** Options that can be used to bind a passive event listener. */ const passiveEventListenerOptions = normalizePassiveListenerOptions({ passive: true }); /** Options that can be used to bind an active event listener. */ const activeEventListenerOptions = normalizePassiveListenerOptions({ passive: false }); /** * Time in milliseconds for which to ignore mouse events, after * receiving a touch event. Used to avoid doing double work for * touch devices where the browser fires fake mouse events, in * addition to touch events. */ const MOUSE_EVENT_IGNORE_TIME = 800; /** Inline styles to be set as `!important` while dragging. */ const dragImportantProperties = new Set([ // Needs to be important, because some `mat-table` sets `position: sticky !important`. See #22781. 'position', ]); /** * Reference to a draggable item. Used to manipulate or dispose of the item. */ export class DragRef { /** Whether starting to drag this element is disabled. */ get disabled() { return this._disabled || !!(this._dropContainer && this._dropContainer.disabled); } set disabled(value) { const newValue = coerceBooleanProperty(value); if (newValue !== this._disabled) { this._disabled = newValue; this._toggleNativeDragInteractions(); this._handles.forEach(handle => toggleNativeDragInteractions(handle, newValue)); } } constructor(element, _config, _document, _ngZone, _viewportRuler, _dragDropRegistry) { this._config = _config; this._document = _document; this._ngZone = _ngZone; this._viewportRuler = _viewportRuler; this._dragDropRegistry = _dragDropRegistry; /** * CSS `transform` applied to the element when it isn't being dragged. We need a * passive transform in order for the dragged element to retain its new position * after the user has stopped dragging and because we need to know the relative * position in case they start dragging again. This corresponds to `element.style.transform`. */ this._passiveTransform = { x: 0, y: 0 }; /** CSS `transform` that is applied to the element while it's being dragged. */ this._activeTransform = { x: 0, y: 0 }; /** * Whether the dragging sequence has been started. Doesn't * necessarily mean that the element has been moved. */ this._hasStartedDragging = false; /** Emits when the item is being moved. */ this._moveEvents = new Subject(); /** Subscription to pointer movement events. */ this._pointerMoveSubscription = Subscription.EMPTY; /** Subscription to the event that is dispatched when the user lifts their pointer. */ this._pointerUpSubscription = Subscription.EMPTY; /** Subscription to the viewport being scrolled. */ this._scrollSubscription = Subscription.EMPTY; /** Subscription to the viewport being resized. */ this._resizeSubscription = Subscription.EMPTY; /** Cached reference to the boundary element. */ this._boundaryElement = null; /** Whether the native dragging interactions have been enabled on the root element. */ this._nativeInteractionsEnabled = true; /** Elements that can be used to drag the draggable item. */ this._handles = []; /** Registered handles that are currently disabled. */ this._disabledHandles = new Set(); /** Layout direction of the item. */ this._direction = 'ltr'; /** * Amount of milliseconds to wait after the user has put their * pointer down before starting to drag the element. */ this.dragStartDelay = 0; this._disabled = false; /** Emits as the drag sequence is being prepared. */ this.beforeStarted = new Subject(); /** Emits when the user starts dragging the item. */ this.started = new Subject(); /** Emits when the user has released a drag item, before any animations have started. */ this.released = new Subject(); /** Emits when the user stops dragging an item in the container. */ this.ended = new Subject(); /** Emits when the user has moved the item into a new container. */ this.entered = new Subject(); /** Emits when the user removes the item its container by dragging it into another container. */ this.exited = new Subject(); /** Emits when the user drops the item inside a container. */ this.dropped = new Subject(); /** * Emits as the user is dragging the item. Use with caution, * because this event will fire for every pixel that the user has dragged. */ this.moved = this._moveEvents; /** Handler for the `mousedown`/`touchstart` events. */ this._pointerDown = (event) => { this.beforeStarted.next(); // Delegate the event based on whether it started from a handle or the element itself. if (this._handles.length) { const targetHandle = this._getTargetHandle(event); if (targetHandle && !this._disabledHandles.has(targetHandle) && !this.disabled) { this._initializeDragSequence(targetHandle, event); } } else if (!this.disabled) { this._initializeDragSequence(this._rootElement, event); } }; /** Handler that is invoked when the user moves their pointer after they've initiated a drag. */ this._pointerMove = (event) => { const pointerPosition = this._getPointerPositionOnPage(event); if (!this._hasStartedDragging) { const distanceX = Math.abs(pointerPosition.x - this._pickupPositionOnPage.x); const distanceY = Math.abs(pointerPosition.y - this._pickupPositionOnPage.y); const isOverThreshold = distanceX + distanceY >= this._config.dragStartThreshold; // Only start dragging after the user has moved more than the minimum distance in either // direction. Note that this is preferable over doing something like `skip(minimumDistance)` // in the `pointerMove` subscription, because we're not guaranteed to have one move event // per pixel of movement (e.g. if the user moves their pointer quickly). if (isOverThreshold) { const isDelayElapsed = Date.now() >= this._dragStartTime + this._getDragStartDelay(event); const container = this._dropContainer; if (!isDelayElapsed) { this._endDragSequence(event); return; } // Prevent other drag sequences from starting while something in the container is still // being dragged. This can happen while we're waiting for the drop animation to finish // and can cause errors, because some elements might still be moving around. if (!container || (!container.isDragging() && !container.isReceiving())) { // Prevent the default action as soon as the dragging sequence is considered as // "started" since waiting for the next event can allow the device to begin scrolling. event.preventDefault(); this._hasStartedDragging = true; this._ngZone.run(() => this._startDragSequence(event)); } } return; } // We prevent the default action down here so that we know that dragging has started. This is // important for touch devices where doing this too early can unnecessarily block scrolling, // if there's a dragging delay. event.preventDefault(); const constrainedPointerPosition = this._getConstrainedPointerPosition(pointerPosition); this._hasMoved = true; this._lastKnownPointerPosition = pointerPosition; this._updatePointerDirectionDelta(constrainedPointerPosition); if (this._dropContainer) { this._updateActiveDropContainer(constrainedPointerPosition, pointerPosition); } else { // If there's a position constraint function, we want the element's top/left to be at the // specific position on the page. Use the initial position as a reference if that's the case. const offset = this.constrainPosition ? this._initialClientRect : this._pickupPositionOnPage; const activeTransform = this._activeTransform; activeTransform.x = constrainedPointerPosition.x - offset.x + this._passiveTransform.x; activeTransform.y = constrainedPointerPosition.y - offset.y + this._passiveTransform.y; this._applyRootElementTransform(activeTransform.x, activeTransform.y); } // Since this event gets fired for every pixel while dragging, we only // want to fire it if the consumer opted into it. Also we have to // re-enter the zone because we run all of the events on the outside. if (this._moveEvents.observers.length) { this._ngZone.run(() => { this._moveEvents.next({ source: this, pointerPosition: constrainedPointerPosition, event, distance: this._getDragDistance(constrainedPointerPosition), delta: this._pointerDirectionDelta, }); }); } }; /** Handler that is invoked when the user lifts their pointer up, after initiating a drag. */ this._pointerUp = (event) => { this._endDragSequence(event); }; /** Handles a native `dragstart` event. */ this._nativeDragStart = (event) => { if (this._handles.length) { const targetHandle = this._getTargetHandle(event); if (targetHandle && !this._disabledHandles.has(targetHandle) && !this.disabled) { event.preventDefault(); } } else if (!this.disabled) { // Usually this isn't necessary since the we prevent the default action in `pointerDown`, // but some cases like dragging of links can slip through (see #24403). event.preventDefault(); } }; this.withRootElement(element).withParent(_config.parentDragRef || null); this._parentPositions = new ParentPositionTracker(_document); _dragDropRegistry.registerDragItem(this); } /** * Returns the element that is being used as a placeholder * while the current element is being dragged. */ getPlaceholderElement() { return this._placeholder; } /** Returns the root draggable element. */ getRootElement() { return this._rootElement; } /** * Gets the currently-visible element that represents the drag item. * While dragging this is the placeholder, otherwise it's the root element. */ getVisibleElement() { return this.isDragging() ? this.getPlaceholderElement() : this.getRootElement(); } /** Registers the handles that can be used to drag the element. */ withHandles(handles) { this._handles = handles.map(handle => coerceElement(handle)); this._handles.forEach(handle => toggleNativeDragInteractions(handle, this.disabled)); this._toggleNativeDragInteractions(); // Delete any lingering disabled handles that may have been destroyed. Note that we re-create // the set, rather than iterate over it and filter out the destroyed handles, because while // the ES spec allows for sets to be modified while they're being iterated over, some polyfills // use an array internally which may throw an error. const disabledHandles = new Set(); this._disabledHandles.forEach(handle => { if (this._handles.indexOf(handle) > -1) { disabledHandles.add(handle); } }); this._disabledHandles = disabledHandles; return this; } /** * Registers the template that should be used for the drag preview. * @param template Template that from which to stamp out the preview. */ withPreviewTemplate(template) { this._previewTemplate = template; return this; } /** * Registers the template that should be used for the drag placeholder. * @param template Template that from which to stamp out the placeholder. */ withPlaceholderTemplate(template) { this._placeholderTemplate = template; return this; } /** * Sets an alternate drag root element. The root element is the element that will be moved as * the user is dragging. Passing an alternate root element is useful when trying to enable * dragging on an element that you might not have access to. */ withRootElement(rootElement) { const element = coerceElement(rootElement); if (element !== this._rootElement) { if (this._rootElement) { this._removeRootElementListeners(this._rootElement); } this._ngZone.runOutsideAngular(() => { element.addEventListener('mousedown', this._pointerDown, activeEventListenerOptions); element.addEventListener('touchstart', this._pointerDown, passiveEventListenerOptions); element.addEventListener('dragstart', this._nativeDragStart, activeEventListenerOptions); }); this._initialTransform = undefined; this._rootElement = element; } if (typeof SVGElement !== 'undefined' && this._rootElement instanceof SVGElement) { this._ownerSVGElement = this._rootElement.ownerSVGElement; } return this; } /** * Element to which the draggable's position will be constrained. */ withBoundaryElement(boundaryElement) { this._boundaryElement = boundaryElement ? coerceElement(boundaryElement) : null; this._resizeSubscription.unsubscribe(); if (boundaryElement) { this._resizeSubscription = this._viewportRuler .change(10) .subscribe(() => this._containInsideBoundaryOnResize()); } return this; } /** Sets the parent ref that the ref is nested in. */ withParent(parent) { this._parentDragRef = parent; return this; } /** Removes the dragging functionality from the DOM element. */ dispose() { this._removeRootElementListeners(this._rootElement); // Do this check before removing from the registry since it'll // stop being considered as dragged once it is removed. if (this.isDragging()) { // Since we move out the element to the end of the body while it's being // dragged, we have to make sure that it's removed if it gets destroyed. this._rootElement?.remove(); } this._anchor?.remove(); this._destroyPreview(); this._destroyPlaceholder(); this._dragDropRegistry.removeDragItem(this); this._removeSubscriptions(); this.beforeStarted.complete(); this.started.complete(); this.released.complete(); this.ended.complete(); this.entered.complete(); this.exited.complete(); this.dropped.complete(); this._moveEvents.complete(); this._handles = []; this._disabledHandles.clear(); this._dropContainer = undefined; this._resizeSubscription.unsubscribe(); this._parentPositions.clear(); this._boundaryElement = this._rootElement = this._ownerSVGElement = this._placeholderTemplate = this._previewTemplate = this._anchor = this._parentDragRef = null; } /** Checks whether the element is currently being dragged. */ isDragging() { return this._hasStartedDragging && this._dragDropRegistry.isDragging(this); } /** Resets a standalone drag item to its initial position. */ reset() { this._rootElement.style.transform = this._initialTransform || ''; this._activeTransform = { x: 0, y: 0 }; this._passiveTransform = { x: 0, y: 0 }; } /** * Sets a handle as disabled. While a handle is disabled, it'll capture and interrupt dragging. * @param handle Handle element that should be disabled. */ disableHandle(handle) { if (!this._disabledHandles.has(handle) && this._handles.indexOf(handle) > -1) { this._disabledHandles.add(handle); toggleNativeDragInteractions(handle, true); } } /** * Enables a handle, if it has been disabled. * @param handle Handle element to be enabled. */ enableHandle(handle) { if (this._disabledHandles.has(handle)) { this._disabledHandles.delete(handle); toggleNativeDragInteractions(handle, this.disabled); } } /** Sets the layout direction of the draggable item. */ withDirection(direction) { this._direction = direction; return this; } /** Sets the container that the item is part of. */ _withDropContainer(container) { this._dropContainer = container; } /** * Gets the current position in pixels the draggable outside of a drop container. */ getFreeDragPosition() { const position = this.isDragging() ? this._activeTransform : this._passiveTransform; return { x: position.x, y: position.y }; } /** * Sets the current position in pixels the draggable outside of a drop container. * @param value New position to be set. */ setFreeDragPosition(value) { this._activeTransform = { x: 0, y: 0 }; this._passiveTransform.x = value.x; this._passiveTransform.y = value.y; if (!this._dropContainer) { this._applyRootElementTransform(value.x, value.y); } return this; } /** * Sets the container into which to insert the preview element. * @param value Container into which to insert the preview. */ withPreviewContainer(value) { this._previewContainer = value; return this; } /** Updates the item's sort order based on the last-known pointer position. */ _sortFromLastPointerPosition() { const position = this._lastKnownPointerPosition; if (position && this._dropContainer) { this._updateActiveDropContainer(this._getConstrainedPointerPosition(position), position); } } /** Unsubscribes from the global subscriptions. */ _removeSubscriptions() { this._pointerMoveSubscription.unsubscribe(); this._pointerUpSubscription.unsubscribe(); this._scrollSubscription.unsubscribe(); } /** Destroys the preview element and its ViewRef. */ _destroyPreview() { this._preview?.remove(); this._previewRef?.destroy(); this._preview = this._previewRef = null; } /** Destroys the placeholder element and its ViewRef. */ _destroyPlaceholder() { this._placeholder?.remove(); this._placeholderRef?.destroy(); this._placeholder = this._placeholderRef = null; } /** * Clears subscriptions and stops the dragging sequence. * @param event Browser event object that ended the sequence. */ _endDragSequence(event) { // Note that here we use `isDragging` from the service, rather than from `this`. // The difference is that the one from the service reflects whether a dragging sequence // has been initiated, whereas the one on `this` includes whether the user has passed // the minimum dragging threshold. if (!this._dragDropRegistry.isDragging(this)) { return; } this._removeSubscriptions(); this._dragDropRegistry.stopDragging(this); this._toggleNativeDragInteractions(); if (this._handles) { this._rootElement.style.webkitTapHighlightColor = this._rootElementTapHighlight; } if (!this._hasStartedDragging) { return; } this.released.next({ source: this, event }); if (this._dropContainer) { // Stop scrolling immediately, instead of waiting for the animation to finish. this._dropContainer._stopScrolling(); this._animatePreviewToPlaceholder().then(() => { this._cleanupDragArtifacts(event); this._cleanupCachedDimensions(); this._dragDropRegistry.stopDragging(this); }); } else { // Convert the active transform into a passive one. This means that next time // the user starts dragging the item, its position will be calculated relatively // to the new passive transform. this._passiveTransform.x = this._activeTransform.x; const pointerPosition = this._getPointerPositionOnPage(event); this._passiveTransform.y = this._activeTransform.y; this._ngZone.run(() => { this.ended.next({ source: this, distance: this._getDragDistance(pointerPosition), dropPoint: pointerPosition, event, }); }); this._cleanupCachedDimensions(); this._dragDropRegistry.stopDragging(this); } } /** Starts the dragging sequence. */ _startDragSequence(event) { if (isTouchEvent(event)) { this._lastTouchEventTime = Date.now(); } this._toggleNativeDragInteractions(); const dropContainer = this._dropContainer; if (dropContainer) { const element = this._rootElement; const parent = element.parentNode; const placeholder = (this._placeholder = this._createPlaceholderElement()); const anchor = (this._anchor = this._anchor || this._document.createComment('')); // Needs to happen before the root element is moved. const shadowRoot = this._getShadowRoot(); // Insert an anchor node so that we can restore the element's position in the DOM. parent.insertBefore(anchor, element); // There's no risk of transforms stacking when inside a drop container so // we can keep the initial transform up to date any time dragging starts. this._initialTransform = element.style.transform || ''; // Create the preview after the initial transform has // been cached, because it can be affected by the transform. this._preview = this._createPreviewElement(); // 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; this._initialIndex = dropContainer.getItemIndex(this); } else { this.started.next({ source: this, event }); this._initialContainer = this._initialIndex = undefined; } // Important to run after we've called `start` on the parent container // so that it has had time to resolve its scrollable parents. this._parentPositions.cache(dropContainer ? dropContainer.getScrollableParents() : []); } /** * Sets up the different variables and subscriptions * that will be necessary for the dragging sequence. * @param referenceElement Element that started the drag sequence. * @param event Browser event object that started the sequence. */ _initializeDragSequence(referenceElement, event) { // Stop propagation if the item is inside another // draggable so we don't start multiple drag sequences. if (this._parentDragRef) { event.stopPropagation(); } const isDragging = this.isDragging(); const isTouchSequence = isTouchEvent(event); const isAuxiliaryMouseButton = !isTouchSequence && event.button !== 0; const rootElement = this._rootElement; const target = _getEventTarget(event); const isSyntheticEvent = !isTouchSequence && this._lastTouchEventTime && this._lastTouchEventTime + MOUSE_EVENT_IGNORE_TIME > Date.now(); const isFakeEvent = isTouchSequence ? isFakeTouchstartFromScreenReader(event) : isFakeMousedownFromScreenReader(event); // If the event started from an element with the native HTML drag&drop, it'll interfere // with our own dragging (e.g. `img` tags do it by default). Prevent the default action // to stop it from happening. Note that preventing on `dragstart` also seems to work, but // it's flaky and it fails if the user drags it away quickly. Also note that we only want // to do this for `mousedown` since doing the same for `touchstart` will stop any `click` // events from firing on touch devices. if (target && target.draggable && event.type === 'mousedown') { event.preventDefault(); } // Abort if the user is already dragging or is using a mouse button other than the primary one. if (isDragging || isAuxiliaryMouseButton || isSyntheticEvent || isFakeEvent) { return; } // If we've got handles, we need to disable the tap highlight on the entire root element, // otherwise iOS will still add it, even though all the drag interactions on the handle // are disabled. if (this._handles.length) { const rootStyles = rootElement.style; this._rootElementTapHighlight = rootStyles.webkitTapHighlightColor || ''; rootStyles.webkitTapHighlightColor = 'transparent'; } this._hasStartedDragging = this._hasMoved = false; // Avoid multiple subscriptions and memory leaks when multi touch // (isDragging check above isn't enough because of possible temporal and/or dimensional delays) this._removeSubscriptions(); this._initialClientRect = this._rootElement.getBoundingClientRect(); this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove); this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp); this._scrollSubscription = this._dragDropRegistry .scrolled(this._getShadowRoot()) .subscribe(scrollEvent => this._updateOnScroll(scrollEvent)); if (this._boundaryElement) { this._boundaryRect = getMutableClientRect(this._boundaryElement); } // If we have a custom preview we can't know ahead of time how large it'll be so we position // it next to the cursor. The exception is when the consumer has opted into making the preview // the same size as the root element, in which case we do know the size. const previewTemplate = this._previewTemplate; this._pickupPositionInElement = previewTemplate && previewTemplate.template && !previewTemplate.matchSize ? { x: 0, y: 0 } : this._getPointerPositionInElement(this._initialClientRect, referenceElement, event); const pointerPosition = (this._pickupPositionOnPage = this._lastKnownPointerPosition = this._getPointerPositionOnPage(event)); this._pointerDirectionDelta = { x: 0, y: 0 }; this._pointerPositionAtLastDirectionChange = { x: pointerPosition.x, y: pointerPosition.y }; this._dragStartTime = Date.now(); this._dragDropRegistry.startDragging(this, event); } /** Cleans up the DOM artifacts that were added to facilitate the element being dragged. */ _cleanupDragArtifacts(event) { // Restore the element's visibility and insert it at its old position in the DOM. // It's important that we maintain the position, because moving the element around in the DOM // can throw off `NgFor` which does smart diffing and re-creates elements only when necessary, // while moving the existing elements in all other cases. toggleVisibility(this._rootElement, true, dragImportantProperties); this._anchor.parentNode.replaceChild(this._rootElement, this._anchor); this._destroyPreview(); this._destroyPlaceholder(); this._initialClientRect = this._boundaryRect = this._previewRect = this._initialTransform = undefined; // Re-enter the NgZone since we bound `document` events on the outside. this._ngZone.run(() => { const container = this._dropContainer; const currentIndex = container.getItemIndex(this); const pointerPosition = this._getPointerPositionOnPage(event); const distance = this._getDragDistance(pointerPosition); const isPointerOverContainer = container._isOverContainer(pointerPosition.x, pointerPosition.y); this.ended.next({ source: this, distance, dropPoint: pointerPosition, event }); this.dropped.next({ item: this, currentIndex, previousIndex: this._initialIndex, container: container, previousContainer: this._initialContainer, isPointerOverContainer, distance, dropPoint: pointerPosition, event, }); container.drop(this, currentIndex, this._initialIndex, this._initialContainer, isPointerOverContainer, distance, pointerPosition, event); this._dropContainer = this._initialContainer; }); } /** * Updates the item's position in its drop container, or moves it * into a new one, depending on its current drag position. */ _updateActiveDropContainer({ x, y }, { x: rawX, y: rawY }) { // Drop container that draggable has been moved into. let newContainer = this._initialContainer._getSiblingContainerFromPosition(this, x, y); // If we couldn't find a new container to move the item into, and the item has left its // initial container, check whether the it's over the initial container. This handles the // case where two containers are connected one way and the user tries to undo dragging an // item into a new container. if (!newContainer && this._dropContainer !== this._initialContainer && this._initialContainer._isOverContainer(x, y)) { newContainer = this._initialContainer; } if (newContainer && newContainer !== this._dropContainer) { this._ngZone.run(() => { // Notify the old container that the item has left. this.exited.next({ item: this, container: this._dropContainer }); this._dropContainer.exit(this); // Notify the new container that the item has entered. this._dropContainer = newContainer; this._dropContainer.enter(this, x, y, newContainer === this._initialContainer && // If we're re-entering the initial container and sorting is disabled, // put item the into its starting index to begin with. newContainer.sortingDisabled ? this._initialIndex : undefined); this.entered.next({ item: this, container: newContainer, currentIndex: newContainer.getItemIndex(this), }); }); } // Dragging may have been interrupted as a result of the events above. if (this.isDragging()) { this._dropContainer._startScrollingIfNecessary(rawX, rawY); this._dropContainer._sortItem(this, x, y, this._pointerDirectionDelta); if (this.constrainPosition) { this._applyPreviewTransform(x, y); } else { this._applyPreviewTransform(x - this._pickupPositionInElement.x, y - this._pickupPositionInElement.y); } } } /** * 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. */ _createPreviewElement() { const previewConfig = this._previewTemplate; const previewClass = this.previewClass; const previewTemplate = previewConfig ? previewConfig.template : null; let preview; 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._initialClientRect : 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._initialClientRect); 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. */ _animatePreviewToPlaceholder() { // If the user hasn't moved yet, the transitionend event won't fire. if (!this._hasMoved) { return Promise.resolve(); } const placeholderRect = this._placeholder.getBoundingClientRect(); // Apply the class that adds a transition to the preview. this._preview.classList.add('cdk-drag-animating'); // Move the preview to the placeholder position. this._applyPreviewTransform(placeholderRect.left, placeholderRect.top); // If the element doesn't have a `transition`, the `transitionend` event won't fire. Since // 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); if (duration === 0) { return Promise.resolve(); } return this._ngZone.runOutsideAngular(() => { return new Promise(resolve => { const handler = ((event) => { if (!event || (_getEventTarget(event) === this._preview && event.propertyName === 'transform')) { this._preview?.removeEventListener('transitionend', handler); resolve(); clearTimeout(timeout); } }); // If a transition is short enough, the browser might not fire the `transitionend` event. // 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, duration * 1.5); this._preview.addEventListener('transitionend', handler); }); }); } /** Creates an element that will be shown instead of the current element while dragging. */ _createPlaceholderElement() { const placeholderConfig = this._placeholderTemplate; const placeholderTemplate = placeholderConfig ? placeholderConfig.template : null; let placeholder; if (placeholderTemplate) { this._placeholderRef = placeholderConfig.viewContainer.createEmbeddedView(placeholderTemplate, placeholderConfig.context); this._placeholderRef.detectChanges(); placeholder = getRootNode(this._placeholderRef, this._document); } else { placeholder = deepCloneNode(this._rootElement); } // Stop pointer events on the preview so the user can't // interact with it while the preview is animating. placeholder.style.pointerEvents = 'none'; placeholder.classList.add('cdk-drag-placeholder'); return placeholder; } /** * Figures out the coordinates at which an element was picked up. * @param referenceElement Element that initiated the dragging. * @param event Event that initiated the dragging. */ _getPointerPositionInElement(elementRect, referenceElement, event) { const handleElement = referenceElement === this._rootElement ? null : referenceElement; const referenceRect = handleElement ? handleElement.getBoundingClientRect() : elementRect; const point = isTouchEvent(event) ? event.targetTouches[0] : event; const scrollPosition = this._getViewportScrollPosition(); const x = point.pageX - referenceRect.left - scrollPosition.left; const y = point.pageY - referenceRect.top - scrollPosition.top; return { x: referenceRect.left - elementRect.left + x, y: referenceRect.top - elementRect.top + y, }; } /** Determines the point of the page that was touched by the user. */ _getPointerPositionOnPage(event) { const scrollPosition = this._getViewportScrollPosition(); const point = isTouchEvent(event) ? // `touches` will be empty for start/end events so we have to fall back to `changedTouches`. // Also note that on real devices we're guaranteed for either `touches` or `changedTouches` // to have a value, but Firefox in device emulation mode has a bug where both can be empty // for `touchstart` and `touchend` so we fall back to a dummy object in order to avoid // throwing an error. The value returned here will be incorrect, but since this only // breaks inside a developer tool and the value is only used for secondary information, // we can get away with it. See https://bugzilla.mozilla.org/show_bug.cgi?id=1615824. event.touches[0] || event.changedTouches[0] || { pageX: 0, pageY: 0 } : event; const x = point.pageX - scrollPosition.left; const y = point.pageY - scrollPosition.top; // if dragging SVG element, try to convert from the screen coordinate system to the SVG // coordinate system if (this._ownerSVGElement) { const svgMatrix = this._ownerSVGElement.getScreenCTM(); if (svgMatrix) { const svgPoint = this._ownerSVGElement.createSVGPoint(); svgPoint.x = x; svgPoint.y = y; return svgPoint.matrixTransform(svgMatrix.inverse()); } } return { x, y }; } /** Gets the pointer position on the page, accounting for any position constraints. */ _getConstrainedPointerPosition(point) { const dropContainerLock = this._dropContainer ? this._dropContainer.lockAxis : null; let { x, y } = this.constrainPosition ? this.constrainPosition(point, this, this._initialClientRect, this._pickupPositionInElement) : point; if (this.lockAxis === 'x' || dropContainerLock === 'x') { y = this._pickupPositionOnPage.y; } else if (this.lockAxis === 'y' || dropContainerLock === 'y') { x = this._pickupPositionOnPage.x; } if (this._boundaryRect) { const { x: pickupX, y: pickupY } = this._pickupPositionInElement; const boundaryRect = this._boundaryRect; const { width: previewWidth, height: previewHeight } = this._getPreviewRect(); const minY = boundaryRect.top + pickupY; const maxY = boundaryRect.bottom - (previewHeight - pickupY); const minX = boundaryRect.left + pickupX; const maxX = boundaryRect.right - (previewWidth - pickupX); x = clamp(x, minX, maxX); y = clamp(y, minY, maxY); } return { x, y }; } /** Updates the current drag delta, based on the user's current pointer position on the page. */ _updatePointerDirectionDelta(pointerPositionOnPage) { const { x, y } = pointerPositionOnPage; const delta = this._pointerDirectionDelta; const positionSinceLastChange = this._pointerPositionAtLastDirectionChange; // Amount of pixels the user has dragged since the last time the direction changed. const changeX = Math.abs(x - positionSinceLastChange.x); const changeY = Math.abs(y - positionSinceLastChange.y); // Because we handle pointer events on a per-pixel basis, we don't want the delta // to change for every pixel, otherwise anything that depends on it can look erratic. // To make the delta more consistent, we track how much the user has moved since the last // delta change and we only update it after it has reached a certain threshold. if (changeX > this._config.pointerDirectionChangeThreshold) { delta.x = x > positionSinceLastChange.x ? 1 : -1; positionSinceLastChange.x = x; } if (changeY > this._config.pointerDirectionChangeThreshold) { delta.y = y > positionSinceLastChange.y ? 1 : -1; positionSinceLastChange.y = y; } return delta; } /** Toggles the native drag interactions, based on how many handles are registered. */ _toggleNativeDragInteractions() { if (!this._rootElement || !this._handles) { return; } const shouldEnable = this._handles.length > 0 || !this.isDragging(); if (shouldEnable !== this._nativeInteractionsEnabled) { this._nativeInteractionsEnabled = shouldEnable; toggleNativeDragInteractions(this._rootElement, shouldEnable); } } /** Removes the manually-added event listeners from the root element. */ _removeRootElementListeners(element) { element.removeEventListener('mousedown', this._pointerDown, activeEventListenerOptions); element.removeEventListener('touchstart', this._pointerDown, passiveEventListenerOptions); element.removeEventListener('dragstart', this._nativeDragStart, activeEventListenerOptions); } /** * Applies a `transform` to the root element, taking into account any existing transforms on it. * @param x New transform value along the X axis. * @param y New transform value along the Y axis. */ _applyRootElementTransform(x, y) { const transform = getTransform(x, y); const styles = this._rootElement.style; // Cache the previous transform amount only after the first drag sequence, because // we don't want our own transforms to stack on top of each other. // Should be excluded none because none + translate3d(x, y, x) is invalid css if (this._initialTransform == null) { this._initialTransform = styles.transform && styles.transform != 'none' ? styles.transform : ''; } // Preserve the previous `transform` value, if there was one. Note that we apply our own // transform before the user's, because things like rotation can affect which direction // the element will be translated towards. styles.transform = combineTransforms(transform, this._initialTransform); } /** * Applies a `transform` to the preview, taking into account any existing transforms on it. * @param x New transform value along the X axis. * @param y New transform value along the Y axis. */ _applyPreviewTransform(x, y) { // Only apply the initial transform if the preview is a clone of the original element, otherwise // 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); } /** * Gets the distance that the user has dragged during the current drag sequence. * @param currentPosition Current position of the user's pointer. */ _getDragDistance(currentPosition) { const pickupPosition = this._pickupPositionOnPage; if (pickupPosition) { return { x: currentPosition.x - pickupPosition.x, y: currentPosition.y - pickupPosition.y }; } return { x: 0, y: 0 }; } /** Cleans up any cached element dimensions that we don't need after dragging has stopped. */ _cleanupCachedDimensions() { this._boundaryRect = this._previewRect = undefined; this._parentPositions.clear(); } /** * Checks whether the element is still inside its boundary after the viewport has been resized. * If not, the position is adjusted so that the element fits again. */ _containInsideBoundaryOnResize() { let { x, y } = this._passiveTransform; if ((x === 0 && y === 0) || this.isDragging() || !this._boundaryElement) { return; } // Note: don't use `_clientRectAtStart` here, because we want the latest position. const elementRect = this._rootElement.getBoundingClientRect(); const boundaryRect = this._boundaryElement.getBoundingClientRect(); // It's possible that the element got hidden away after dragging (e.g. by switching to a // different tab). Don't do anything in this case so we don't clear the user's position. if ((boundaryRect.width === 0 && boundaryRect.height === 0) || (elementRect.width === 0 && elementRect.height === 0)) { return; } const leftOverflow = boundaryRect.left - elementRect.left; const rightOverflow = elementRect.right - boundaryRect.right; const topOverflow = boundaryRect.top - elementRect.top;