From 1bfeb78868e464259811defb1eea31d736f9922a Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:31:24 +0100 Subject: [PATCH] test+design: add E2E drag-drop tests and design wireframes E2E tests cover: - Drag & drop image into editor inserts image block with data URL - Drop zone overlay appears during image drag - Non-image file drops are correctly ignored Design file shows three states: idle, drag-over (dashed border + overlay label), and after-drop (image block inserted inline). Co-Authored-By: Claude Opus 4.6 --- design/drag-drop-images.pen | 222 ++++++++++++++++++++++++++++++++++++ e2e/image-drag-drop.spec.ts | 185 ++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 design/drag-drop-images.pen create mode 100644 e2e/image-drag-drop.spec.ts diff --git a/design/drag-drop-images.pen b/design/drag-drop-images.pen new file mode 100644 index 00000000..d26f56f7 --- /dev/null +++ b/design/drag-drop-images.pen @@ -0,0 +1,222 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "ddi01", + "name": "Drag & Drop — Idle State", + "x": 0, + "y": 0, + "width": 760, + "height": 500, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "ddi02", + "content": "Editor — Normal State", + "fontSize": 13, + "fontWeight": 600, + "fill": "$--muted-foreground", + "padding": 16 + }, + { + "type": "frame", + "id": "ddi03", + "name": "Editor Content Area", + "layout": "vertical", + "fill": "$--background", + "width": "fill_container", + "height": "fill_container", + "padding": 40, + "gap": 8, + "children": [ + { + "type": "text", + "id": "ddi04", + "content": "# My Note Title", + "fontSize": 24, + "fontWeight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "ddi05", + "content": "Some paragraph text in the editor. The user can drag images from Finder directly into this area.", + "fontSize": 15, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "ddi06", + "content": "Another paragraph of content below.", + "fontSize": 15, + "fill": "$--foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ddi10", + "name": "Drag & Drop — Drag Over State", + "x": 860, + "y": 0, + "width": 760, + "height": 500, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "ddi11", + "content": "Editor — Image Dragged Over", + "fontSize": 13, + "fontWeight": 600, + "fill": "$--muted-foreground", + "padding": 16 + }, + { + "type": "frame", + "id": "ddi12", + "name": "Editor Content Area (drag-over)", + "layout": "vertical", + "width": "fill_container", + "height": "fill_container", + "padding": 40, + "gap": 8, + "stroke": "$--primary", + "strokeWidth": 2, + "strokeStyle": "dashed", + "fill": "rgba(21, 93, 255, 0.06)", + "cornerRadius": 0, + "children": [ + { + "type": "text", + "id": "ddi13", + "content": "# My Note Title", + "fontSize": 24, + "fontWeight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "ddi14", + "content": "Some paragraph text in the editor.", + "fontSize": 15, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ddi15", + "name": "Drop Overlay Label", + "layout": "horizontal", + "padding": 12, + "cornerRadius": 8, + "fill": "$--background", + "alignSelf": "center", + "children": [ + { + "type": "text", + "id": "ddi16", + "content": "Drop image here", + "fontSize": 14, + "fontWeight": 500, + "fill": "$--primary" + } + ] + }, + { + "type": "text", + "id": "ddi17", + "content": "Another paragraph of content below.", + "fontSize": 15, + "fill": "$--foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ddi20", + "name": "Drag & Drop — Image Inserted", + "x": 0, + "y": 600, + "width": 760, + "height": 560, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "ddi21", + "content": "Editor — After Image Drop", + "fontSize": 13, + "fontWeight": 600, + "fill": "$--muted-foreground", + "padding": 16 + }, + { + "type": "frame", + "id": "ddi22", + "name": "Editor Content Area (with image)", + "layout": "vertical", + "fill": "$--background", + "width": "fill_container", + "height": "fill_container", + "padding": 40, + "gap": 8, + "children": [ + { + "type": "text", + "id": "ddi23", + "content": "# My Note Title", + "fontSize": 24, + "fontWeight": 700, + "fill": "$--foreground" + }, + { + "type": "text", + "id": "ddi24", + "content": "Some paragraph text in the editor.", + "fontSize": 15, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ddi25", + "name": "Inserted Image Block", + "width": "fill_container", + "height": 200, + "fill": "#E8E8E8", + "cornerRadius": 4, + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "ddi26", + "content": "[Dropped image preview — photo.jpg]", + "fontSize": 13, + "fill": "$--muted-foreground", + "textAlign": "center" + } + ] + }, + { + "type": "text", + "id": "ddi27", + "content": "Another paragraph of content below the inserted image.", + "fontSize": 15, + "fill": "$--foreground" + } + ] + } + ] + } + ], + "variables": {}, + "themes": {} +} diff --git a/e2e/image-drag-drop.spec.ts b/e2e/image-drag-drop.spec.ts new file mode 100644 index 00000000..a058fced --- /dev/null +++ b/e2e/image-drag-drop.spec.ts @@ -0,0 +1,185 @@ +import { test, expect } from '@playwright/test' +import * as path from 'path' +import * as fs from 'fs' + +// Minimal valid PNG: 1x1 red pixel +const TEST_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + +function createTestImage(filepath: string) { + fs.mkdirSync(path.dirname(filepath), { recursive: true }) + fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) +} + +test('drag & drop image into editor inserts image block', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + await editor.click() + await page.waitForTimeout(200) + + // Create a test image file + const testImagePath = path.join(process.cwd(), 'test-results', 'drag-test-image.png') + createTestImage(testImagePath) + + // Screenshot before + await page.screenshot({ path: 'test-results/drag-drop-before.png', fullPage: true }) + + // Simulate drag-and-drop of a file into the editor + // Playwright supports dispatching drag events with DataTransfer + const editorContainer = page.locator('.editor__blocknote-container') + const box = await editorContainer.boundingBox() + expect(box).toBeTruthy() + + // Read the file as a buffer for the DataTransfer + const fileBuffer = fs.readFileSync(testImagePath) + + // Use Playwright's page.dispatchEvent with a custom script to simulate file drop + await page.evaluate(async ({ base64, x, y }) => { + const container = document.querySelector('.editor__blocknote-container') + if (!container) throw new Error('Editor container not found') + + // Convert base64 to Uint8Array + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + + // Create a File object + const file = new File([bytes], 'drag-test-image.png', { type: 'image/png' }) + + // Create DataTransfer with the file + const dt = new DataTransfer() + dt.items.add(file) + + // Dispatch dragover first (to set the drop effect) + const dragOverEvent = new DragEvent('dragover', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }) + container.dispatchEvent(dragOverEvent) + + // Then dispatch drop + const dropEvent = new DragEvent('drop', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }) + container.dispatchEvent(dropEvent) + }, { + base64: TEST_PNG_BASE64, + x: box!.x + box!.width / 2, + y: box!.y + box!.height / 2, + }) + + // Wait for the image to be uploaded and inserted + await page.waitForTimeout(2000) + + // Verify: image element exists in the editor + const images = page.locator('.bn-editor img') + await expect(images.first()).toBeVisible({ timeout: 5000 }) + + // Verify: image uses data URL (browser mode) + const src = await images.first().getAttribute('src') + expect(src).toMatch(/^data:/) + + await page.screenshot({ path: 'test-results/drag-drop-after.png', fullPage: true }) + + // Clean up + if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) +}) + +test('drop zone overlay appears during image drag', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + + const editorContainer = page.locator('.editor__blocknote-container') + const box = await editorContainer.boundingBox() + expect(box).toBeTruthy() + + // Simulate dragover with an image file to trigger overlay + await page.evaluate(({ x, y }) => { + const container = document.querySelector('.editor__blocknote-container') + if (!container) throw new Error('Editor container not found') + + const file = new File([new Uint8Array(1)], 'test.png', { type: 'image/png' }) + const dt = new DataTransfer() + dt.items.add(file) + + const event = new DragEvent('dragover', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }) + container.dispatchEvent(event) + }, { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }) + + // The drop overlay should be visible + const overlay = page.locator('.editor__drop-overlay') + await expect(overlay).toBeVisible({ timeout: 2000 }) + await expect(overlay).toContainText('Drop image here') + + await page.screenshot({ path: 'test-results/drag-drop-overlay.png', fullPage: true }) +}) + +test('non-image file drop is ignored', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + + const editorContainer = page.locator('.editor__blocknote-container') + const box = await editorContainer.boundingBox() + expect(box).toBeTruthy() + + // Count images before + const imagesBefore = await page.locator('.bn-editor img').count() + + // Simulate dropping a text file + await page.evaluate(({ x, y }) => { + const container = document.querySelector('.editor__blocknote-container') + if (!container) throw new Error('Editor container not found') + + const file = new File(['not an image'], 'readme.txt', { type: 'text/plain' }) + const dt = new DataTransfer() + dt.items.add(file) + + container.dispatchEvent(new DragEvent('drop', { + dataTransfer: dt, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + })) + }, { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }) + + await page.waitForTimeout(500) + + // No new images should have been added + const imagesAfter = await page.locator('.bn-editor img').count() + expect(imagesAfter).toBe(imagesBefore) +})