// This file polyfills DragEvent for jsdom // https://github.com/jsdom/jsdom/issues/2913 // This file is in JS rather than TS, as our jsdom setup files currently need to be in JS // Good news: DragEvents are almost the same as MouseEvents (() => { if (typeof window === 'undefined') { return; } // Polyfill not needed if (typeof window.DragEvent !== 'undefined') { return; } // Let's create what we need for DragEvent's! if (window.DataTransferItemList) { throw new Error(`Unexpected global found: "DataTransferItemList"`); } if (window.DataTransfer) { throw new Error(`Unexpected global found: "DataTransfer"`); } // Using this so we can quickly look up an items // data without needing to go through the public async API // to get item values const fastItemValueLookup = Symbol('item-value'); /** * Note: this polyfill does not implement "read/write", "read-only" or "protected" * permissions for `DataTransferItemList` or `DataTransfer`. * Adding these permissions in make it impossible to set values like `.types` or `.items` * in events other than `"dragstart"`, which we commonly want to be able to set in tests * * Examples: * * - You often want to add `.items` for a `"drop"` event (to test you can pull the `.items` out) * but `.items` can only be set in `"dragstart"`. * * - Similarly, you often want to access `.types` in drag events, * but they can only be set in `"dragstart"` */ /** @type DataTransferItemList * * Cheating an making `DataTransferItemList` extend an `Array` so we can get: * - `list.length` for free * - indexed lookup (`list[0]`) for free * - makes other operations such as clearing, finding, adding, removing easy as well */ class DataTransferItemList extends Array { /** * @param {(string | File)} stringValueOrFile * @param {string=} stringMimeType * @return {(DataTransferItem | null)} * https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransferitemlist-add */ add(stringValueOrFile, stringMimeType) { if (stringValueOrFile instanceof File) { /** @type DataTransferItem */ const item = { kind: 'file', // The type of file being dragged (eg "image/jpeg") type: stringValueOrFile.type, getAsFile: () => { return stringValueOrFile; }, getAsString: (/* callback */) => { // callback will never be resolved for files }, webkitGetAsEntry() { throw new Error('webkitGetAsEntry() not implemented'); }, // This allows us to lookup items synchronously with `dataTransfer.getData()` [fastItemValueLookup]: stringValueOrFile, }; this.push(item); return item; } if (typeof stringValueOrFile === 'string') { // `type` gets converted to lowercase according to the spec const type = stringMimeType.toLocaleLowerCase(); // Throws if adding data to a type that already has data const exists = this.some( item => item.kind === 'string' && item.type === type, ); if (exists) { throw new DOMException('NotSupportedError'); } /** @type DataTransferItem */ const item = { kind: 'string', type, getAsFile: () => { // this will be `null` for non-files return null; }, getAsString: callback => { setTimeout(() => { callback(stringValueOrFile); }); }, webkitGetAsEntry() { throw new Error('webkitGetAsEntry() not implemented'); }, // This allows us to lookup items synchronously with `dataTransfer.getData()` [fastItemValueLookup]: stringValueOrFile, }; this.push(item); return item; } throw new Error( 'Unexpected arguments. Expected: .add(file: File) or .add(data: string, type: string)', ); } /** Removes an item at a given index * @param {number} index * @return {void} */ remove(index) { this.splice(index, 1); } /** Removes all items * @return {void} */ clear() { this.length = 0; } } window.DataTransferItemList = DataTransferItemList; /** * @param {string} format * * Get the full media type, adjusting for shorthand lookup values. * https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-getdata * */ function getFormat(format) { const lower = format.toLocaleLowerCase(); // shorthands if (lower === 'text') { return { format: 'text/plain', convertToURL: true }; } // From spec: // If format equals "url", change it to "text/uri-list" and set convert-to-URL to true. if (lower === 'url') { return { format: 'text/uri-list', convertToURL: true }; } return { format: lower, convertToURL: false }; } /** * @type DataTransfer * * https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface */ class DataTransfer { constructor() { // From spec: // > Set the dropEffect and effectAllowed to "none". this.dropEffect = 'none'; // Not implementing mode restrictions so this can be set in testing // for any event this.effectAllowed = 'none'; // DataTransferItemList() is usually a hidden constructor this.items = new DataTransferItemList(); } /** * Get unique types of `.items` * @return {string[]} * https://html.spec.whatwg.org/multipage/dnd.html#concept-datatransfer-types */ get types() { const all = this.items.map(item => { if (item.kind === 'string') { return item.type; } return 'Files'; }); // it is possible to have multiple 'Files' entries // so we need to strip them out const unique = Array.from(new Set(all)); // sorting for consistency return unique.sort(); } /** * Get files being dragged * @return {FileList} */ get files() { return this.items .filter(item => item.kind === 'file') .reduce((acc, item, index) => { const file = item.getAsFile(); acc[index] = file; return acc; }, {}); } /** Clears string items. Note: cannot be used to clear files * * @param {string=} format * @return {void} * @see https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-cleardata */ clearData(format) { if (format) { const actualFormat = getFormat(format).format; const index = this.items.findIndex(item => { // Note: can never clear files with `clearData` return item.type === actualFormat; }); if (index !== -1) { this.items.remove(index); } return; } // According to the spec, `.clearData()` does not remove files. // However, in Chrome it does remove files... // Looping backwards so that we can safely remove // items without messing up indexes for (let i = this.items.length - 1; i >= 0; i--) { const item = this.items[i]; if (item.kind === 'string') { this.items.remove(i); } } } /** This function is only used to get the value of string items * * @param {string} format * @return {string} * @see https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-getdata */ getData(format) { const result = getFormat(format); const match = this.items.find( item => item.kind === 'string' && item.type === result.format, ); if (!match) { return ''; } const value = match[fastItemValueLookup]; if (!result.convertToURL) { return value; } // From spec: // If convert-to-URL is true, then parse result as appropriate for text/uri-list data, and then set result to the first URL from the list, if any, or the empty string otherwise. [RFC2483] const urls = value // You can have multiple urls split by CR+LF (EOL) // - CR: Carriage Return '\r' // - LF: Line Feed '\n' // - EOL: End of Line '\r\n' .split('\r\n') // a uri-list can have comment lines starting with '#' // so we need to remove those .filter(piece => !piece.startsWith('#')); return urls[0] ?? ''; } /** This function is only used to set string items * * @param {string} format * @param {string} data * @return {void} * @see https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-setdata */ setData(format, data) { const actualFormat = getFormat(format).format; // clear existing item with matching format this.clearData(actualFormat); this.items.add(data, actualFormat); } // eslint-disable-next-line class-methods-use-this setDragImage() { // doesn't do anything for our polyfill } } window.DataTransfer = DataTransfer; class DragEvent extends MouseEvent { constructor(type, eventInitDict = {}) { super(type, eventInitDict); // MouseEvent in jsdom doesn't implement the standard pageX and pageY properties this.pageX = eventInitDict.pageX ?? 0; this.pageY = eventInitDict.pageY ?? 0; this.dataTransfer = new DataTransfer(); } } window.DragEvent = DragEvent; })();