/ shared / components / src / actions / allow-drop.ts
allow-drop.ts
  1  import type { ActionReturn } from 'svelte/action';
  2  import { get } from 'svelte/store';
  3  import { activeDragHandler } from '@amp/web-app-components/src/actions/allow-drag';
  4  
  5  /*
  6      FOLLOW-UP WORK:
  7      - it now adds and destroys the handler, but doesn't have a update method.
  8        We might want to keep track of any DropHandler that got created for an element and just update the existing instance.
  9        rdar://98074771 (Onyx: DnD: Update allow-drag and allow-drop actions to support updates)
 10  */
 11  
 12  const DROP_AREA_DATA_ATTR = 'data-drop-area';
 13  const DRAG_OVER_CLASS = 'is-drag-over';
 14  
 15  export type DropOptions = {
 16      dropEnabled: boolean;
 17      onDrop: (details: DropData) => void;
 18      targets?:
 19          | [DropTarget]
 20          | [DropTarget.Top, DropTarget.Bottom]
 21          | [DropTarget.Left, DropTarget.Right];
 22      dropEffect?: DataTransfer['dropEffect'];
 23  };
 24  
 25  export type DropData = {
 26      data: unknown;
 27      dropTarget?: DropTarget;
 28  };
 29  
 30  export enum DropTarget {
 31      Top = 'top',
 32      Bottom = 'bottom',
 33      Left = 'left',
 34      Right = 'right',
 35  }
 36  
 37  const DRAG_OVER_CLASSES = {
 38      default: DRAG_OVER_CLASS,
 39      [DropTarget.Top]: `${DRAG_OVER_CLASS}-${DropTarget.Top}`,
 40      [DropTarget.Bottom]: `${DRAG_OVER_CLASS}-${DropTarget.Bottom}`,
 41      [DropTarget.Left]: `${DRAG_OVER_CLASS}-${DropTarget.Left}`,
 42      [DropTarget.Right]: `${DRAG_OVER_CLASS}-${DropTarget.Right}`,
 43  };
 44  
 45  class DropHandler {
 46      private readonly element: HTMLElement;
 47      private readonly options: DropOptions;
 48      private enterTarget: HTMLElement;
 49      private target: DropTarget;
 50      private lastPosition: number;
 51  
 52      constructor(element: HTMLElement, options: DropOptions) {
 53          this.element = element;
 54          this.options = options;
 55  
 56          this.addEventListeners();
 57      }
 58  
 59      private addEventListeners = (): void => {
 60          this.element.setAttribute(DROP_AREA_DATA_ATTR, '');
 61          this.element.addEventListener('dragenter', this.onDragEnter);
 62          this.element.addEventListener('dragover', this.onDragOver);
 63          this.element.addEventListener('dragleave', this.onDragLeave);
 64          this.element.addEventListener('drop', this.onDrop);
 65      };
 66  
 67      private removeEventListeners = (): void => {
 68          this.element.removeEventListener('dragenter', this.onDragEnter);
 69          this.element.removeEventListener('dragover', this.onDragOver);
 70          this.element.removeEventListener('dragleave', this.onDragLeave);
 71          this.element.removeEventListener('drop', this.onDrop);
 72      };
 73  
 74      public destroy = (): void => {
 75          this.resetState();
 76          this.element.removeAttribute(DROP_AREA_DATA_ATTR);
 77          this.removeEventListeners();
 78      };
 79  
 80      private resetState = (): void => {
 81          this.enterTarget = null;
 82          this.target = null;
 83          this.lastPosition = null;
 84          this.removeDragOverClasses();
 85      };
 86  
 87      private removeDragOverClasses = (): void => {
 88          Object.keys(DRAG_OVER_CLASSES).forEach((key) => {
 89              this.element.classList.remove(DRAG_OVER_CLASSES[key]);
 90          });
 91      };
 92  
 93      private setDragOverClass = (targetName: DropTarget): void => {
 94          const target = targetName || this.target;
 95          const dragOverClass =
 96              DRAG_OVER_CLASSES[target] || DRAG_OVER_CLASSES.default;
 97          // add right target class if not yet present
 98          if (!this.element.classList.contains(dragOverClass)) {
 99              this.removeDragOverClasses(); // clear all target classes before switching target
100              this.element.classList.add(dragOverClass);
101          }
102      };
103  
104      /**
105       * getLocationTarget: this function determines in what target region the user currently is
106       *
107       * @param e DragEvent
108       * @param threshold threshold for the target location switch zone
109       * @returns DropTarget
110       */
111      private getLocationTarget = (e: DragEvent, threshold = 0): DropTarget => {
112          const { targets } = this.options;
113  
114          // Do not check on drag over region when it has no or one target
115          if (!targets || targets.length === 1) {
116              this.target = targets?.[0];
117              return this.target;
118          }
119  
120          let position, size;
121  
122          // When using top - bottom targets
123          if (targets.join('-') === `${DropTarget.Top}-${DropTarget.Bottom}`) {
124              // offset to drop area, instead of target (which could be a child)
125              position = e.clientY - this.element.getBoundingClientRect().top;
126              size = this.element.offsetHeight;
127          }
128          // When using left - right targets
129          else if (
130              targets.join('-') === `${DropTarget.Left}-${DropTarget.Right}`
131          ) {
132              // offset to drop area, instead of target (which could be a child)
133              position = e.clientX - this.element.getBoundingClientRect().left;
134              size = this.element.offsetWidth;
135          }
136  
137          if (position && size) {
138              if (
139                  !this.lastPosition ||
140                  Math.abs(position - this.lastPosition) > threshold
141              ) {
142                  this.lastPosition = position;
143                  this.target = position <= size / 2 ? targets[0] : targets[1];
144              }
145          }
146  
147          return this.target;
148      };
149  
150      private isCompatibleDropEffect(e: DragEvent) {
151          // Workaround for https://bugs.webkit.org/show_bug.cgi?id=178058
152          // There is a longstanding WebKit bug where any value set by the user
153          // on `dataTransfer.effectAllowed` in the dragstart event is ignored
154          // and always returns "all". This means that we cannot trust the value
155          // that is set in the DragEvent. As a workaround, we store and check
156          // the active drag handler for the effectAllowed specified in the options.
157          //
158          // const { dropEffect, effectAllowed } = e.dataTransfer;
159          const { dropEffect } = e.dataTransfer;
160          const effectAllowed = get(activeDragHandler)?.getEffectAllowed();
161  
162          return (
163              effectAllowed === 'all' ||
164              effectAllowed.toLowerCase().includes(dropEffect)
165          );
166      }
167  
168      private onDragEnter = (e: DragEvent): void => {
169          e.dataTransfer.dropEffect = this.options.dropEffect || 'copy';
170  
171          if (!this.isCompatibleDropEffect(e)) {
172              return;
173          }
174  
175          e.stopPropagation();
176  
177          // Set enterTarget to cover entering child elements
178          this.enterTarget = e.target as HTMLElement;
179          this.setDragOverClass(this.getLocationTarget(e));
180      };
181  
182      private onDragOver = (e: DragEvent): void => {
183          e.dataTransfer.dropEffect = this.options.dropEffect || 'copy';
184  
185          if (!this.isCompatibleDropEffect(e)) {
186              return;
187          }
188  
189          e.preventDefault(); // prevent the browser from default handling of the data to allow drop
190          e.stopPropagation(); // prevent setting classes on parent drop areas
191          this.setDragOverClass(this.getLocationTarget(e, 10));
192      };
193  
194      private onDragLeave = (e: Event): void => {
195          // Only set drag-over to false when it leaves the drop area. Not on entering childs
196          if (e.target === this.enterTarget) {
197              this.resetState();
198          }
199      };
200  
201      private onDrop = (e: DragEvent): void => {
202          e.preventDefault();
203          e.stopPropagation(); // Prevent drop action on parent elements
204  
205          const data = JSON.parse(e.dataTransfer.getData('text/plain'));
206          const draggedData: DropData = { data };
207  
208          if (this.target) {
209              draggedData.dropTarget = this.target;
210          }
211  
212          this.resetState();
213          this.options.onDrop(draggedData);
214      };
215  }
216  
217  /**
218   * Allow Drop action
219   *
220   * Usage:
221   * <div use:allow-drop={{ dropEnabled: true, onDrop: dropAction }}></div>
222   */
223  export function allowDrop(
224      target: HTMLElement,
225      options: DropOptions,
226  ): ActionReturn<DropOptions> {
227      let dropHandler;
228  
229      if (options?.dropEnabled && options?.onDrop) {
230          dropHandler = new DropHandler(target, options);
231      }
232  
233      return {
234          destroy: () => {
235              dropHandler?.destroy();
236          },
237          update: (updatedOptions: DropOptions) => {
238              // Hotfix for updated properties. Remove handlers with data and add new ones.
239              // TODO: rdar://98074771 (Onyx: DnD: Update allow-drag and allow-drop actions to support updates)
240              dropHandler?.destroy();
241  
242              if (updatedOptions?.dropEnabled && updatedOptions?.onDrop) {
243                  dropHandler = new DropHandler(target, updatedOptions);
244              }
245          },
246      };
247  }
248  
249  export default allowDrop;