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;