From eb8a9a35462bc4495fdb601480e682d890dd985c Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sat, 6 Apr 2024 07:42:09 -0400 Subject: [PATCH] fix(cdk/drag-drop): text selection not disabled inside shadow dom on firefox Fixes that text selection wasn't being disabled when the `cdkDrag` directive is inside the shadow DOM on Firefox. The issue appears to be that the `selectstart` event wasn't crossing the shadow boundary so we have to bind it at the shadow root as well. Fixes #28792. --- src/cdk/drag-drop/directives/drag.spec.ts | 40 ++++++++++++++++++++++ src/cdk/drag-drop/drag-ref.ts | 41 +++++++++++++++++++---- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/cdk/drag-drop/directives/drag.spec.ts b/src/cdk/drag-drop/directives/drag.spec.ts index 67cbdd6a2f71..06f5aa6e2014 100644 --- a/src/cdk/drag-drop/directives/drag.spec.ts +++ b/src/cdk/drag-drop/directives/drag.spec.ts @@ -6527,6 +6527,46 @@ describe('CdkDrag', () => { }); })); + it('should prevent selection at the shadow root level', fakeAsync(() => { + // This test is only relevant for Shadow DOM-supporting browsers. + if (!_supportsShadowDom()) { + return; + } + + const fixture = createComponent( + ConnectedDropZones, + [], + undefined, + [], + ViewEncapsulation.ShadowDom, + ); + fixture.detectChanges(); + + const shadowRoot = fixture.nativeElement.shadowRoot; + const item = fixture.componentInstance.groupedDragItems[0][1]; + + startDraggingViaMouse(fixture, item.element.nativeElement); + fixture.detectChanges(); + + const initialSelectStart = dispatchFakeEvent( + shadowRoot, + 'selectstart', + ); + fixture.detectChanges(); + expect(initialSelectStart.defaultPrevented).toBe(true); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + + const afterDropSelectStart = dispatchFakeEvent( + shadowRoot, + 'selectstart', + ); + fixture.detectChanges(); + expect(afterDropSelectStart.defaultPrevented).toBe(false); + })); + it('should not throw if its next sibling is removed while dragging', fakeAsync(() => { const fixture = createComponent(ConnectedDropZonesWithSingleItems); fixture.detectChanges(); diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 68a1ff0a00c3..8cdad4161a78 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -58,6 +58,12 @@ const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: tr /** Options that can be used to bind an active event listener. */ const activeEventListenerOptions = normalizePassiveListenerOptions({passive: false}); +/** Event options that can be used to bind an active, capturing event. */ +const activeCapturingEventOptions = normalizePassiveListenerOptions({ + passive: false, + capture: true, +}); + /** * Time in milliseconds for which to ignore mouse events, after * receiving a touch event. Used to avoid doing double work for @@ -496,7 +502,7 @@ export class DragRef { this._destroyPreview(); this._destroyPlaceholder(); this._dragDropRegistry.removeDragItem(this); - this._removeSubscriptions(); + this._removeListeners(); this.beforeStarted.complete(); this.started.complete(); this.released.complete(); @@ -608,10 +614,15 @@ export class DragRef { } /** Unsubscribes from the global subscriptions. */ - private _removeSubscriptions() { + private _removeListeners() { this._pointerMoveSubscription.unsubscribe(); this._pointerUpSubscription.unsubscribe(); this._scrollSubscription.unsubscribe(); + this._getShadowRoot()?.removeEventListener( + 'selectstart', + shadowDomSelectStart, + activeCapturingEventOptions, + ); } /** Destroys the preview element and its ViewRef. */ @@ -741,7 +752,7 @@ export class DragRef { return; } - this._removeSubscriptions(); + this._removeListeners(); this._dragDropRegistry.stopDragging(this); this._toggleNativeDragInteractions(); @@ -792,17 +803,28 @@ export class DragRef { this._toggleNativeDragInteractions(); + // Needs to happen before the root element is moved. + const shadowRoot = this._getShadowRoot(); const dropContainer = this._dropContainer; + if (shadowRoot) { + // In some browsers the global `selectstart` that we maintain in the `DragDropRegistry` + // doesn't cross the shadow boundary so we have to prevent it at the shadow root (see #28792). + this._ngZone.runOutsideAngular(() => { + shadowRoot.addEventListener( + 'selectstart', + shadowDomSelectStart, + activeCapturingEventOptions, + ); + }); + } + if (dropContainer) { const element = this._rootElement; const parent = element.parentNode as HTMLElement; 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); @@ -888,7 +910,7 @@ export class DragRef { // 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._removeListeners(); this._initialDomRect = this._rootElement.getBoundingClientRect(); this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove); this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp); @@ -1617,3 +1639,8 @@ function matchElementSize(target: HTMLElement, sourceRect: DOMRect): void { 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(); +}