Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: drag-drop files into editors + paste images into docs

Reusable drop overlay component (drop-overlay.ts) with drag counter
pattern, file type validation, and show/hide overlay.

Docs editor: drop .docx/.md/.txt/.html + image files (.png/.jpg/.gif/
.webp/.svg). Paste images from clipboard. Images inserted as base64
data URLs via TipTap Image extension with 5MB size limit.

Sheets editor: drop .xlsx/.csv/.tsv files directly into the grid.

27 new tests for drop overlay utilities.

+449
+138
src/drop-overlay.ts
··· 1 + /** 2 + * Reusable drag-and-drop overlay for file import. 3 + * 4 + * Encapsulates the drag counter pattern, overlay display, file extension 5 + * validation, and cleanup. Reuses the `.drop-overlay` CSS class from app.css. 6 + */ 7 + 8 + /** 9 + * Get the lowercase file extension (without dot) from a filename. 10 + * @param {string} filename 11 + * @returns {string|null} 12 + */ 13 + export function getFileExtension(filename) { 14 + if (!filename || typeof filename !== 'string') return null; 15 + const dotIdx = filename.lastIndexOf('.'); 16 + if (dotIdx < 0 || dotIdx === filename.length - 1) return null; 17 + return filename.slice(dotIdx).toLowerCase(); 18 + } 19 + 20 + /** 21 + * Check whether a filename's extension is in the accepted list. 22 + * @param {string} filename 23 + * @param {string[]} acceptedExtensions - e.g. ['.docx', '.md', '.txt'] 24 + * @returns {boolean} 25 + */ 26 + export function isAcceptedFile(filename, acceptedExtensions) { 27 + const ext = getFileExtension(filename); 28 + if (!ext) return false; 29 + return acceptedExtensions.includes(ext); 30 + } 31 + 32 + /** 33 + * Build the overlay DOM element that is shown during a drag. 34 + * @param {string} label - Hint text for the overlay 35 + * @returns {HTMLElement} 36 + */ 37 + function buildOverlayElement(label) { 38 + const overlay = document.createElement('div'); 39 + overlay.className = 'drop-overlay'; 40 + overlay.innerHTML = ` 41 + <div class="drop-overlay-content"> 42 + <div class="drop-overlay-icon">\u2B07</div> 43 + <div class="drop-overlay-text">Drop file to import</div> 44 + <div class="drop-overlay-hint">${label}</div> 45 + </div> 46 + `; 47 + return overlay; 48 + } 49 + 50 + /** 51 + * Create drag-and-drop event listeners on a container element. 52 + * 53 + * Shows a `.drop-overlay` during drag, validates the dropped file's extension, 54 + * and calls `onDrop(file)` for valid files. 55 + * 56 + * Uses a drag enter/leave counter to avoid flicker when dragging over children. 57 + * 58 + * @param {HTMLElement} container - The element to listen on 59 + * @param {Object} opts 60 + * @param {string[]} opts.acceptedExtensions - Accepted file extensions (e.g. ['.docx', '.md']) 61 + * @param {function(File): void} opts.onDrop - Callback when a valid file is dropped 62 + * @param {string} [opts.label] - Descriptive hint text shown in the overlay 63 + * @param {function(string, number): void} [opts.onReject] - Called with a message when a file is rejected 64 + * @returns {{ destroy(): void }} Cleanup handle 65 + */ 66 + export function createDropOverlay(container, { acceptedExtensions, onDrop, label, onReject }) { 67 + let dragCounter = 0; 68 + let overlayEl = null; 69 + 70 + const defaultLabel = acceptedExtensions.map(e => e.replace('.', '')).join(', '); 71 + const displayLabel = label || `Supported: ${defaultLabel}`; 72 + 73 + function showOverlay() { 74 + if (overlayEl) return; 75 + overlayEl = buildOverlayElement(displayLabel); 76 + container.appendChild(overlayEl); 77 + } 78 + 79 + function hideOverlay() { 80 + if (overlayEl) { 81 + overlayEl.remove(); 82 + overlayEl = null; 83 + } 84 + } 85 + 86 + function handleDragEnter(e) { 87 + e.preventDefault(); 88 + dragCounter++; 89 + if (dragCounter === 1) showOverlay(); 90 + } 91 + 92 + function handleDragOver(e) { 93 + e.preventDefault(); 94 + e.dataTransfer.dropEffect = 'copy'; 95 + } 96 + 97 + function handleDragLeave(e) { 98 + e.preventDefault(); 99 + dragCounter--; 100 + if (dragCounter <= 0) { 101 + dragCounter = 0; 102 + hideOverlay(); 103 + } 104 + } 105 + 106 + function handleDrop(e) { 107 + e.preventDefault(); 108 + dragCounter = 0; 109 + hideOverlay(); 110 + 111 + const file = e.dataTransfer?.files?.[0]; 112 + if (!file) return; 113 + 114 + if (!isAcceptedFile(file.name, acceptedExtensions)) { 115 + const ext = getFileExtension(file.name) || 'unknown'; 116 + const msg = `Unsupported file type: ${ext}`; 117 + if (onReject) onReject(msg, 4000); 118 + return; 119 + } 120 + 121 + onDrop(file); 122 + } 123 + 124 + container.addEventListener('dragenter', handleDragEnter); 125 + container.addEventListener('dragover', handleDragOver); 126 + container.addEventListener('dragleave', handleDragLeave); 127 + container.addEventListener('drop', handleDrop); 128 + 129 + return { 130 + destroy() { 131 + container.removeEventListener('dragenter', handleDragEnter); 132 + container.removeEventListener('dragover', handleDragOver); 133 + container.removeEventListener('dragleave', handleDragLeave); 134 + container.removeEventListener('drop', handleDrop); 135 + hideOverlay(); 136 + }, 137 + }; 138 + }
+311
tests/drop-overlay.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 3 + import { getFileExtension, isAcceptedFile, createDropOverlay } from '../src/drop-overlay.js'; 4 + 5 + // --- getFileExtension --- 6 + 7 + describe('getFileExtension', () => { 8 + it('returns the extension with dot for normal filenames', () => { 9 + expect(getFileExtension('report.docx')).toBe('.docx'); 10 + expect(getFileExtension('data.csv')).toBe('.csv'); 11 + expect(getFileExtension('image.png')).toBe('.png'); 12 + }); 13 + 14 + it('is case-insensitive (returns lowercase)', () => { 15 + expect(getFileExtension('FILE.DOCX')).toBe('.docx'); 16 + expect(getFileExtension('Image.PNG')).toBe('.png'); 17 + expect(getFileExtension('Sheet.XLSX')).toBe('.xlsx'); 18 + }); 19 + 20 + it('handles filenames with multiple dots', () => { 21 + expect(getFileExtension('my.report.final.docx')).toBe('.docx'); 22 + expect(getFileExtension('q1.budget.2026.xlsx')).toBe('.xlsx'); 23 + }); 24 + 25 + it('returns null for filenames without extensions', () => { 26 + expect(getFileExtension('README')).toBeNull(); 27 + }); 28 + 29 + it('returns null for empty or invalid input', () => { 30 + expect(getFileExtension('')).toBeNull(); 31 + expect(getFileExtension(null)).toBeNull(); 32 + expect(getFileExtension(undefined)).toBeNull(); 33 + expect(getFileExtension(123)).toBeNull(); 34 + }); 35 + 36 + it('returns null for filenames ending with a dot', () => { 37 + expect(getFileExtension('file.')).toBeNull(); 38 + }); 39 + }); 40 + 41 + // --- isAcceptedFile --- 42 + 43 + describe('isAcceptedFile', () => { 44 + const docExtensions = ['.docx', '.md', '.txt']; 45 + const sheetExtensions = ['.xlsx', '.csv', '.tsv']; 46 + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']; 47 + 48 + it('returns true for accepted doc extensions', () => { 49 + expect(isAcceptedFile('report.docx', docExtensions)).toBe(true); 50 + expect(isAcceptedFile('notes.md', docExtensions)).toBe(true); 51 + expect(isAcceptedFile('readme.txt', docExtensions)).toBe(true); 52 + }); 53 + 54 + it('returns true for accepted sheet extensions', () => { 55 + expect(isAcceptedFile('budget.xlsx', sheetExtensions)).toBe(true); 56 + expect(isAcceptedFile('data.csv', sheetExtensions)).toBe(true); 57 + expect(isAcceptedFile('export.tsv', sheetExtensions)).toBe(true); 58 + }); 59 + 60 + it('returns true for accepted image extensions', () => { 61 + expect(isAcceptedFile('photo.png', imageExtensions)).toBe(true); 62 + expect(isAcceptedFile('photo.jpg', imageExtensions)).toBe(true); 63 + expect(isAcceptedFile('photo.jpeg', imageExtensions)).toBe(true); 64 + expect(isAcceptedFile('anim.gif', imageExtensions)).toBe(true); 65 + expect(isAcceptedFile('logo.webp', imageExtensions)).toBe(true); 66 + expect(isAcceptedFile('icon.svg', imageExtensions)).toBe(true); 67 + }); 68 + 69 + it('returns false for non-accepted extensions', () => { 70 + expect(isAcceptedFile('image.png', docExtensions)).toBe(false); 71 + expect(isAcceptedFile('report.docx', sheetExtensions)).toBe(false); 72 + expect(isAcceptedFile('archive.zip', docExtensions)).toBe(false); 73 + }); 74 + 75 + it('is case-insensitive', () => { 76 + expect(isAcceptedFile('FILE.DOCX', docExtensions)).toBe(true); 77 + expect(isAcceptedFile('DATA.CSV', sheetExtensions)).toBe(true); 78 + expect(isAcceptedFile('PHOTO.PNG', imageExtensions)).toBe(true); 79 + }); 80 + 81 + it('returns false for empty or invalid input', () => { 82 + expect(isAcceptedFile('', docExtensions)).toBe(false); 83 + expect(isAcceptedFile(null, docExtensions)).toBe(false); 84 + expect(isAcceptedFile(undefined, docExtensions)).toBe(false); 85 + }); 86 + 87 + it('returns false for filenames with no extension', () => { 88 + expect(isAcceptedFile('README', docExtensions)).toBe(false); 89 + }); 90 + }); 91 + 92 + // --- createDropOverlay --- 93 + 94 + describe('createDropOverlay', () => { 95 + let container; 96 + 97 + beforeEach(() => { 98 + container = document.createElement('div'); 99 + document.body.appendChild(container); 100 + }); 101 + 102 + afterEach(() => { 103 + container.remove(); 104 + }); 105 + 106 + function createDragEvent(type, files = []) { 107 + const event = new Event(type, { bubbles: true, cancelable: true }); 108 + event.dataTransfer = { 109 + files, 110 + dropEffect: '', 111 + }; 112 + event.preventDefault = vi.fn(); 113 + return event; 114 + } 115 + 116 + it('shows overlay on dragenter', () => { 117 + createDropOverlay(container, { 118 + acceptedExtensions: ['.docx'], 119 + onDrop: vi.fn(), 120 + }); 121 + 122 + container.dispatchEvent(createDragEvent('dragenter')); 123 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 124 + }); 125 + 126 + it('hides overlay on dragleave', () => { 127 + createDropOverlay(container, { 128 + acceptedExtensions: ['.docx'], 129 + onDrop: vi.fn(), 130 + }); 131 + 132 + container.dispatchEvent(createDragEvent('dragenter')); 133 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 134 + 135 + container.dispatchEvent(createDragEvent('dragleave')); 136 + expect(container.querySelector('.drop-overlay')).toBeNull(); 137 + }); 138 + 139 + it('handles nested drag enter/leave with counter pattern', () => { 140 + createDropOverlay(container, { 141 + acceptedExtensions: ['.docx'], 142 + onDrop: vi.fn(), 143 + }); 144 + 145 + // Enter parent 146 + container.dispatchEvent(createDragEvent('dragenter')); 147 + // Enter child (counter = 2) 148 + container.dispatchEvent(createDragEvent('dragenter')); 149 + // Leave child (counter = 1) — overlay still visible 150 + container.dispatchEvent(createDragEvent('dragleave')); 151 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 152 + 153 + // Leave parent (counter = 0) — overlay removed 154 + container.dispatchEvent(createDragEvent('dragleave')); 155 + expect(container.querySelector('.drop-overlay')).toBeNull(); 156 + }); 157 + 158 + it('calls onDrop with the file for accepted extensions', () => { 159 + const onDrop = vi.fn(); 160 + createDropOverlay(container, { 161 + acceptedExtensions: ['.docx', '.md'], 162 + onDrop, 163 + }); 164 + 165 + const file = new File(['content'], 'report.docx', { type: 'application/vnd.openxmlformats' }); 166 + container.dispatchEvent(createDragEvent('drop', [file])); 167 + 168 + expect(onDrop).toHaveBeenCalledWith(file); 169 + }); 170 + 171 + it('does not call onDrop for rejected extensions', () => { 172 + const onDrop = vi.fn(); 173 + createDropOverlay(container, { 174 + acceptedExtensions: ['.docx'], 175 + onDrop, 176 + }); 177 + 178 + const file = new File(['content'], 'image.png', { type: 'image/png' }); 179 + container.dispatchEvent(createDragEvent('drop', [file])); 180 + 181 + expect(onDrop).not.toHaveBeenCalled(); 182 + }); 183 + 184 + it('calls onReject with a message for rejected files', () => { 185 + const onReject = vi.fn(); 186 + createDropOverlay(container, { 187 + acceptedExtensions: ['.docx'], 188 + onDrop: vi.fn(), 189 + onReject, 190 + }); 191 + 192 + const file = new File(['content'], 'image.png', { type: 'image/png' }); 193 + container.dispatchEvent(createDragEvent('drop', [file])); 194 + 195 + expect(onReject).toHaveBeenCalledWith('Unsupported file type: .png', 4000); 196 + }); 197 + 198 + it('hides overlay after drop', () => { 199 + createDropOverlay(container, { 200 + acceptedExtensions: ['.docx'], 201 + onDrop: vi.fn(), 202 + }); 203 + 204 + container.dispatchEvent(createDragEvent('dragenter')); 205 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 206 + 207 + const file = new File(['content'], 'report.docx', { type: 'application/vnd.openxmlformats' }); 208 + container.dispatchEvent(createDragEvent('drop', [file])); 209 + expect(container.querySelector('.drop-overlay')).toBeNull(); 210 + }); 211 + 212 + it('sets dropEffect to copy on dragover', () => { 213 + createDropOverlay(container, { 214 + acceptedExtensions: ['.docx'], 215 + onDrop: vi.fn(), 216 + }); 217 + 218 + const event = createDragEvent('dragover'); 219 + container.dispatchEvent(event); 220 + expect(event.dataTransfer.dropEffect).toBe('copy'); 221 + }); 222 + 223 + it('prevents default on dragover and drop events', () => { 224 + createDropOverlay(container, { 225 + acceptedExtensions: ['.docx'], 226 + onDrop: vi.fn(), 227 + }); 228 + 229 + const dragoverEvent = createDragEvent('dragover'); 230 + container.dispatchEvent(dragoverEvent); 231 + expect(dragoverEvent.preventDefault).toHaveBeenCalled(); 232 + 233 + const file = new File(['x'], 'test.docx'); 234 + const dropEvent = createDragEvent('drop', [file]); 235 + container.dispatchEvent(dropEvent); 236 + expect(dropEvent.preventDefault).toHaveBeenCalled(); 237 + }); 238 + 239 + it('destroy() removes all listeners and hides overlay', () => { 240 + const onDrop = vi.fn(); 241 + const handle = createDropOverlay(container, { 242 + acceptedExtensions: ['.docx'], 243 + onDrop, 244 + }); 245 + 246 + // Show overlay first 247 + container.dispatchEvent(createDragEvent('dragenter')); 248 + expect(container.querySelector('.drop-overlay')).not.toBeNull(); 249 + 250 + // Destroy 251 + handle.destroy(); 252 + expect(container.querySelector('.drop-overlay')).toBeNull(); 253 + 254 + // After destroy, events should not trigger onDrop 255 + const file = new File(['content'], 'report.docx'); 256 + container.dispatchEvent(createDragEvent('drop', [file])); 257 + expect(onDrop).not.toHaveBeenCalled(); 258 + }); 259 + 260 + it('shows custom label in overlay', () => { 261 + createDropOverlay(container, { 262 + acceptedExtensions: ['.xlsx'], 263 + onDrop: vi.fn(), 264 + label: 'Drop spreadsheets here', 265 + }); 266 + 267 + container.dispatchEvent(createDragEvent('dragenter')); 268 + const hint = container.querySelector('.drop-overlay-hint'); 269 + expect(hint.textContent).toBe('Drop spreadsheets here'); 270 + }); 271 + 272 + it('shows default label listing extensions when no custom label', () => { 273 + createDropOverlay(container, { 274 + acceptedExtensions: ['.docx', '.md'], 275 + onDrop: vi.fn(), 276 + }); 277 + 278 + container.dispatchEvent(createDragEvent('dragenter')); 279 + const hint = container.querySelector('.drop-overlay-hint'); 280 + expect(hint.textContent).toContain('docx'); 281 + expect(hint.textContent).toContain('md'); 282 + }); 283 + 284 + it('does nothing when drop has no files', () => { 285 + const onDrop = vi.fn(); 286 + const onReject = vi.fn(); 287 + createDropOverlay(container, { 288 + acceptedExtensions: ['.docx'], 289 + onDrop, 290 + onReject, 291 + }); 292 + 293 + container.dispatchEvent(createDragEvent('drop', [])); 294 + expect(onDrop).not.toHaveBeenCalled(); 295 + expect(onReject).not.toHaveBeenCalled(); 296 + }); 297 + 298 + it('only creates one overlay even with multiple dragenter events', () => { 299 + createDropOverlay(container, { 300 + acceptedExtensions: ['.docx'], 301 + onDrop: vi.fn(), 302 + }); 303 + 304 + container.dispatchEvent(createDragEvent('dragenter')); 305 + container.dispatchEvent(createDragEvent('dragenter')); 306 + container.dispatchEvent(createDragEvent('dragenter')); 307 + 308 + const overlays = container.querySelectorAll('.drop-overlay'); 309 + expect(overlays.length).toBe(1); 310 + }); 311 + });