diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index b53cf8fbd7..a7abd2b950 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -448,6 +448,26 @@ function calculateTypographyMetrics( }; } +/** + * Wraps `calculateTypographyMetrics` and applies inline-image height override. + * + * Typography metrics (ascent, descent) stay text-based so the baseline doesn't + * shift. When the line contains an inline image taller than the text line height, + * lineHeight is expanded to the image height — matching Word's behaviour where + * the text baseline stays fixed and the image occupies exactly its own height. + */ +function finalizeLineMetrics( + line: { maxFontSize: number; maxFontInfo?: FontInfo; maxImageHeight?: number }, + spacing?: ParagraphSpacing, +): { ascent: number; descent: number; lineHeight: number } { + const metrics = calculateTypographyMetrics(line.maxFontSize, spacing, line.maxFontInfo); + const imageH = line.maxImageHeight ?? 0; + if (imageH > metrics.lineHeight) { + metrics.lineHeight = imageH; + } + return metrics; +} + /** * Calculates typography metrics for empty paragraphs. * @@ -1048,6 +1068,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P maxFontSize: number; /** Font info for the run with maxFontSize, used for accurate typography metrics */ maxFontInfo?: FontInfo; + /** Tallest inline image on this line (pixels) */ + maxImageHeight?: number; maxWidth: number; segments: Line['segments']; leaders?: Line['leaders']; @@ -1275,7 +1297,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P if ((run as Run).kind === 'break') { if (currentLine) { - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, ...metrics }; addBarTabsToLine(completedLine); @@ -1307,7 +1329,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // For leading line breaks (before any text), use fallback font info for accurate height calculation const lineBreakFontInfo = hasSeenTextRun ? undefined : fallbackFontInfo; if (currentLine) { - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const completedLine: Line = { ...currentLine, ...metrics, @@ -1489,7 +1511,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P toRun: runIndex, toChar: 1, // Images are treated as single atomic units width: imageWidth, - maxFontSize: imageHeight, // Use image height for line height calculation + maxFontSize: 0, + maxImageHeight: imageHeight, maxWidth: getEffectiveWidth(lines.length === 0 ? initialAvailableWidth : bodyContentWidth), spaceCount: 0, segments: [ @@ -1518,7 +1541,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P if (!skipFitCheck && currentLine.width + imageWidth > currentLine.maxWidth && currentLine.width > 0) { // Image doesn't fit - finish current line and start new line with image trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, @@ -1538,7 +1561,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P toRun: runIndex, toChar: 1, width: imageWidth, - maxFontSize: imageHeight, + maxFontSize: 0, + maxImageHeight: imageHeight, maxWidth: getEffectiveWidth(bodyContentWidth), spaceCount: 0, segments: [ @@ -1555,7 +1579,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P currentLine.toRun = runIndex; currentLine.toChar = 1; currentLine.width = roundValue(currentLine.width + imageWidth); - currentLine.maxFontSize = Math.max(currentLine.maxFontSize, imageHeight); + currentLine.maxImageHeight = Math.max(currentLine.maxImageHeight ?? 0, imageHeight); if (!currentLine.segments) currentLine.segments = []; currentLine.segments.push({ runIndex, @@ -1668,7 +1692,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P if (currentLine.width + annotationWidth > currentLine.maxWidth && currentLine.width > 0) { // Doesn't fit - finish current line and start new one trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, @@ -1772,7 +1796,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P currentLine.width > 0 ) { trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, @@ -1886,7 +1910,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P ) { // Space doesn't fit - finish current line and start new one with the space trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, @@ -1969,7 +1993,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // long word can use the pending tab alignment. if (currentLine && currentLine.width > 0 && currentLine.segments && currentLine.segments.length > 0) { trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, @@ -2036,7 +2060,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P } else { // More chunks to come - finish this line and push it trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, @@ -2182,7 +2206,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P if (shouldBreak) { trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, @@ -2247,7 +2271,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P appendSegment(currentLine.segments, runIndex, wordStartChar, wordEndNoSpace, wordOnlyWidth, explicitXHere); // finish current line and start a new one on next iteration trimTrailingWrapSpaces(currentLine); - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const completedLine: Line = { ...lineBase, ...metrics }; addBarTabsToLine(completedLine); @@ -2386,7 +2410,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P } if (currentLine) { - const metrics = calculateTypographyMetrics(currentLine.maxFontSize, spacing, currentLine.maxFontInfo); + const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; const finalLine: Line = { ...lineBase, diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 52f79c887a..6d58932723 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -6,12 +6,7 @@ import { getActiveFormatting } from '@core/helpers/getActiveFormatting.js'; import { findParentNode } from '@helpers/index.js'; import { vClickOutside } from '@superdoc/common'; import Toolbar from './Toolbar.vue'; -import { - checkAndProcessImage, - replaceSelectionWithImagePlaceholder, - uploadAndInsertImage, - getFileOpener, -} from '../../extensions/image/imageHelpers/index.js'; +import { getFileOpener, processAndInsertImageFile } from '../../extensions/image/imageHelpers/index.js'; import { toolbarIcons } from './toolbarIcons.js'; import { toolbarTexts } from './toolbarTexts.js'; import { getQuickFormatList } from '@extensions/linked-styles/index.js'; @@ -459,29 +454,12 @@ export class SuperToolbar extends EventEmitter { return; } - const { size, file } = await checkAndProcessImage({ + await processAndInsertImageFile({ file: result.file, - getMaxContentSize: () => this.activeEditor.getMaxContentSize(), - }); - - if (!file) { - return; - } - - const id = {}; - - replaceSelectionWithImagePlaceholder({ - view: this.activeEditor.view, - editorOptions: this.activeEditor.options, - id, - }); - - await uploadAndInsertImage({ editor: this.activeEditor, view: this.activeEditor.view, - file, - size, - id, + editorOptions: this.activeEditor.options, + getMaxContentSize: () => this.activeEditor.getMaxContentSize(), }); } catch (error) { const err = new Error('[super-toolbar 🎨] Image upload failed'); diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 576633b1b7..4d90607edc 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -65,6 +65,7 @@ import { shouldUseCellSelection as shouldUseCellSelectionFromHelper, } from './tables/TableSelectionUtilities.js'; import { DragDropManager } from './input/DragDropManager.js'; +import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; import { decodeRPrFromMarks } from '../super-converter/styles.js'; import { halfPointToPoints } from '../super-converter/helpers.js'; @@ -2764,7 +2765,7 @@ export class PresentationEditor extends EventEmitter { } /** - * Sets up drag and drop handlers for field annotations. + * Sets up drag and drop handlers for field annotations and image files. */ #setupDragHandlers() { // Clean up any existing manager @@ -2777,6 +2778,7 @@ export class PresentationEditor extends EventEmitter { scheduleSelectionUpdate: () => this.#scheduleSelectionUpdate(), getViewportHost: () => this.#viewportHost, getPainterHost: () => this.#painterHost, + insertImageFile: (params) => processAndInsertImageFile(params), }); this.#dragDropManager.bind(); } diff --git a/packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts b/packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts index 19b35d46c0..7c0bed7ab9 100644 --- a/packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts +++ b/packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts @@ -1,9 +1,9 @@ /** * DragDropManager - Consolidated drag and drop handling for PresentationEditor. * - * This manager handles all drag/drop events for field annotations: - * - Internal drags (moving annotations within the document) - * - External drags (inserting annotations from external sources like palettes) + * This manager handles all drag/drop events for: + * - Field annotations (internal moves and external inserts) + * - Image files (drag from OS/other apps into the editor) * - Window-level fallback for drops on overlay elements */ @@ -25,6 +25,9 @@ export const FIELD_ANNOTATION_DATA_TYPE = 'fieldAnnotation' as const; // Types // ============================================================================= +/** Classifies what kind of data a drag event carries. */ +export type DropPayloadKind = 'fieldAnnotation' | 'imageFiles' | 'none'; + /** * Attributes for a field annotation node. */ @@ -66,6 +69,17 @@ export interface FieldAnnotationDragData { attributes?: Record; } +/** + * Callback to process and insert a single image file into the editor. + */ +export type ImageInsertHandler = (params: { + file: File; + editor: Editor; + view: Editor['view']; + editorOptions: Editor['options']; + getMaxContentSize: () => { width?: number; height?: number }; +}) => Promise<'success' | 'skipped'>; + /** * Dependencies injected from PresentationEditor. */ @@ -80,10 +94,12 @@ export type DragDropDependencies = { getViewportHost: () => HTMLElement; /** The painter host element (for internal drag detection) */ getPainterHost: () => HTMLElement; + /** Handler for inserting a single dropped image file */ + insertImageFile: ImageInsertHandler; }; // ============================================================================= -// Helpers +// Helpers — Field Annotations // ============================================================================= /** @@ -176,6 +192,74 @@ function extractDragData(event: DragEvent): FieldAnnotationDragData | null { } } +// ============================================================================= +// Helpers — Payload Classification +// ============================================================================= + +/** + * Checks if a drag event may contain files. + * + * During dragover, `dataTransfer.files` is empty due to browser security + * restrictions — only `dataTransfer.types` is available. This function checks + * the types array, which works for both dragover and drop events. + * + * Note: This cannot distinguish image files from other file types during + * dragover. Actual image filtering happens at drop time via `getDroppedImageFiles`. + */ +export function hasPossibleFiles(event: DragEvent): boolean { + return event.dataTransfer?.types?.includes('Files') ?? false; +} + +/** Image extensions used as fallback when File.type is empty. */ +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tiff', '.tif']); + +/** + * Checks whether a File looks like an image by MIME type or, when the type + * is empty (some OS/browser drag sources omit it), by file extension. + */ +function looksLikeImage(file: File): boolean { + if (file.type.startsWith('image/')) return true; + if (file.type === '') { + const dotIndex = file.name.lastIndexOf('.'); + if (dotIndex !== -1) { + return IMAGE_EXTENSIONS.has(file.name.slice(dotIndex).toLowerCase()); + } + } + return false; +} + +/** + * Extracts image File objects from a drop event's dataTransfer. + * Only usable on drop events — files are not accessible during dragover. + */ +export function getDroppedImageFiles(event: DragEvent): File[] { + const files = event.dataTransfer?.files; + if (!files) return []; + const images: File[] = []; + for (let i = 0; i < files.length; i++) { + if (looksLikeImage(files[i])) { + images.push(files[i]); + } + } + return images; +} + +/** + * Classifies the drag payload kind. Evaluated in order — first match wins. + * + * Field annotations take precedence over files in mixed payloads, + * since they are an internal editor concept with stricter semantics. + * + * During dragover, this returns 'imageFiles' for any file drag (since we + * can't inspect file types yet). On drop, callers use `getDroppedImageFiles` + * to filter to actual images. + */ +export function getDropPayloadKind(event: DragEvent): DropPayloadKind { + if (hasFieldAnnotationData(event)) return 'fieldAnnotation'; + if (hasPossibleFiles(event)) return 'imageFiles'; + return 'none'; +} + // ============================================================================= // DragDropManager Class // ============================================================================= @@ -220,11 +304,11 @@ export class DragDropManager { // Attach listeners to painter host (for internal drags) painterHost.addEventListener('dragstart', this.#boundHandleDragStart); painterHost.addEventListener('dragend', this.#boundHandleDragEnd); - painterHost.addEventListener('dragleave', this.#boundHandleDragLeave); - // Attach listeners to viewport host (for all drags) + // Attach listeners to viewport host (for all drags including external image files) viewportHost.addEventListener('dragover', this.#boundHandleDragOver); viewportHost.addEventListener('drop', this.#boundHandleDrop); + viewportHost.addEventListener('dragleave', this.#boundHandleDragLeave); // Window-level listeners for overlay fallback window.addEventListener('dragover', this.#boundHandleWindowDragOver, false); @@ -243,15 +327,15 @@ export class DragDropManager { if (this.#boundHandleDragEnd) { painterHost.removeEventListener('dragend', this.#boundHandleDragEnd); } - if (this.#boundHandleDragLeave) { - painterHost.removeEventListener('dragleave', this.#boundHandleDragLeave); - } if (this.#boundHandleDragOver) { viewportHost.removeEventListener('dragover', this.#boundHandleDragOver); } if (this.#boundHandleDrop) { viewportHost.removeEventListener('drop', this.#boundHandleDrop); } + if (this.#boundHandleDragLeave) { + viewportHost.removeEventListener('dragleave', this.#boundHandleDragLeave); + } if (this.#boundHandleWindowDragOver) { window.removeEventListener('dragover', this.#boundHandleWindowDragOver, false); } @@ -276,7 +360,7 @@ export class DragDropManager { } // ========================================================================== - // Event Handlers + // Event Handlers — Top-level entry points // ========================================================================== /** @@ -308,24 +392,91 @@ export class DragDropManager { } /** - * Handle dragover - update cursor position to show drop location. + * Handle dragover - branch by payload kind and update cursor position. */ #handleDragOver(event: DragEvent): void { if (!this.#deps) return; - if (!hasFieldAnnotationData(event)) return; + + const kind = getDropPayloadKind(event); + if (kind === 'none') return; const activeEditor = this.#deps.getActiveEditor(); if (!activeEditor?.isEditable) return; event.preventDefault(); + if (event.dataTransfer) { - event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + if (kind === 'fieldAnnotation') { + event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + } else { + event.dataTransfer.dropEffect = 'copy'; + } } // Coalesce dragover selection updates to one per animation frame. this.#scheduleDragOverSelection(event.clientX, event.clientY); } + /** + * Handle drop - branch by payload kind and dispatch to the appropriate handler. + */ + #handleDrop(event: DragEvent): void { + if (!this.#deps) return; + + const kind = getDropPayloadKind(event); + if (kind === 'none') return; + + event.preventDefault(); + event.stopPropagation(); + this.#cancelPendingDragOverSelection(); + + const activeEditor = this.#deps.getActiveEditor(); + if (!activeEditor?.isEditable) return; + + if (kind === 'imageFiles') { + this.#handleImageDrop(event); + return; + } + + // Field annotation drop + const { state, view } = activeEditor; + if (!state || !view) return; + + const hit = this.#deps.hitTest(event.clientX, event.clientY); + const fallbackPos = state.selection?.from ?? state.doc?.content.size ?? null; + const dropPos = hit?.pos ?? fallbackPos; + if (dropPos == null) return; + + if (isInternalDrag(event)) { + this.#handleInternalDrop(event, dropPos); + return; + } + + this.#handleExternalDrop(event, dropPos); + } + + #handleDragEnd(_event: DragEvent): void { + this.#cancelPendingDragOverSelection(); + this.#deps?.getPainterHost()?.classList.remove('drag-over'); + } + + #handleDragLeave(event: DragEvent): void { + const viewportHost = this.#deps?.getViewportHost(); + if (!viewportHost) return; + + // Only clean up when the drag truly leaves the viewport, not when + // crossing internal child element boundaries. + const relatedTarget = event.relatedTarget as Node | null; + if (relatedTarget && viewportHost.contains(relatedTarget)) return; + + this.#cancelPendingDragOverSelection(); + this.#deps?.getPainterHost()?.classList.remove('drag-over'); + } + + // ========================================================================== + // RAF Coalescing — Shared by all payload kinds during dragover + // ========================================================================== + #scheduleDragOverSelection(clientX: number, clientY: number): void { if (!this.#deps) return; this.#pendingDragOver = { x: clientX, y: clientY }; @@ -373,40 +524,109 @@ export class DragDropManager { } } + // ========================================================================== + // Image Drop + // ========================================================================== + /** - * Handle drop - either move internal annotation or insert external one. + * Handle drop of image files from the OS or another application. */ - #handleDrop(event: DragEvent): void { + async #handleImageDrop(event: DragEvent): Promise { if (!this.#deps) return; - if (!hasFieldAnnotationData(event)) return; - - event.preventDefault(); - event.stopPropagation(); - - this.#cancelPendingDragOverSelection(); const activeEditor = this.#deps.getActiveEditor(); - if (!activeEditor?.isEditable) return; - const { state, view } = activeEditor; if (!state || !view) return; - // Get drop position - const hit = this.#deps.hitTest(event.clientX, event.clientY); - const fallbackPos = state.selection?.from ?? state.doc?.content.size ?? null; - const dropPos = hit?.pos ?? fallbackPos; + const imageFiles = getDroppedImageFiles(event); + if (imageFiles.length === 0) return; + + // Resolve insertion position: hitTest → current selection → document end + const dropPos = this.#resolveDropPosition(event.clientX, event.clientY); if (dropPos == null) return; - // Handle internal drag (move existing annotation) - if (isInternalDrag(event)) { - this.#handleInternalDrop(event, dropPos); - return; + // Set selection at drop position before inserting + this.#setSelectionAt(dropPos); + + // Process files sequentially for deterministic ordering. + // Errors on individual files are caught so remaining files still insert. + for (const file of imageFiles) { + try { + await this.#deps.insertImageFile({ + file, + editor: activeEditor, + view: activeEditor.view, + editorOptions: activeEditor.options, + getMaxContentSize: () => activeEditor.getMaxContentSize(), + }); + } catch { + // Skip failed file, continue with remaining + } } - // Handle external drag (insert new annotation) - this.#handleExternalDrop(event, dropPos); + // Focus editor and update selection overlay + this.#focusEditor(); + this.#deps.scheduleSelectionUpdate(); } + /** + * Resolves a drop position using the hitTest → selection → document-end fallback chain. + */ + #resolveDropPosition(clientX: number, clientY: number): number | null { + if (!this.#deps) return null; + + const activeEditor = this.#deps.getActiveEditor(); + const { state } = activeEditor; + if (!state) return null; + + const hit = this.#deps.hitTest(clientX, clientY); + if (hit?.pos != null) return hit.pos; + + // Fallback: current PM selection position + if (state.selection?.from != null) return state.selection.from; + + // Last resort: document end + return state.doc?.content.size ?? null; + } + + /** + * Sets a text selection at the given position (clamped to document bounds). + */ + #setSelectionAt(pos: number): void { + if (!this.#deps) return; + + const activeEditor = this.#deps.getActiveEditor(); + const doc = activeEditor.state?.doc; + if (!doc) return; + + const clampedPos = Math.min(Math.max(pos, 1), doc.content.size); + try { + const tr = activeEditor.state.tr + .setSelection(TextSelection.create(doc, clampedPos)) + .setMeta('addToHistory', false); + activeEditor.view?.dispatch(tr); + } catch { + // Position may be invalid during layout updates + } + } + + /** + * Focuses the hidden ProseMirror editor after a drop. + */ + #focusEditor(): void { + if (!this.#deps) return; + const activeEditor = this.#deps.getActiveEditor(); + const editorDom = activeEditor.view?.dom as HTMLElement | undefined; + if (editorDom) { + editorDom.focus(); + activeEditor.view?.focus(); + } + } + + // ========================================================================== + // Field Annotation Drop + // ========================================================================== + /** * Handle internal drop - move field annotation within document. */ @@ -504,35 +724,20 @@ export class DragDropManager { this.#deps.scheduleSelectionUpdate(); } - // Focus editor - const editorDom = activeEditor.view?.dom as HTMLElement | undefined; - if (editorDom) { - editorDom.focus(); - activeEditor.view?.focus(); - } - } - - #handleDragEnd(_event: DragEvent): void { - this.#cancelPendingDragOverSelection(); - // Remove visual feedback - this.#deps?.getPainterHost()?.classList.remove('drag-over'); + this.#focusEditor(); } - #handleDragLeave(event: DragEvent): void { - const painterHost = this.#deps?.getPainterHost(); - if (!painterHost) return; - - const relatedTarget = event.relatedTarget as Node | null; - if (!relatedTarget || !painterHost.contains(relatedTarget)) { - painterHost.classList.remove('drag-over'); - } - } + // ========================================================================== + // Window-level Fallback + // ========================================================================== /** * Window-level dragover to allow drops on overlay elements. + * Prevents browser default navigation for both field annotations and files. */ #handleWindowDragOver(event: DragEvent): void { - if (!hasFieldAnnotationData(event)) return; + const kind = getDropPayloadKind(event); + if (kind === 'none') return; const viewportHost = this.#deps?.getViewportHost(); const target = event.target as HTMLElement; @@ -541,19 +746,24 @@ export class DragDropManager { if (viewportHost?.contains(target)) return; event.preventDefault(); + if (event.dataTransfer) { - event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + if (kind === 'fieldAnnotation') { + event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + } else { + event.dataTransfer.dropEffect = 'copy'; + } } - - // Still update cursor position for overlay drops - this.#handleDragOver(event); } /** * Window-level drop to catch drops on overlay elements. + * Routes all recognized payloads through `#handleDrop` so images and + * field annotations both work when dropped on overlays. */ #handleWindowDrop(event: DragEvent): void { - if (!hasFieldAnnotationData(event)) return; + const kind = getDropPayloadKind(event); + if (kind === 'none') return; const viewportHost = this.#deps?.getViewportHost(); const target = event.target as HTMLElement; diff --git a/packages/super-editor/src/core/presentation-editor/tests/DragDropManager.test.ts b/packages/super-editor/src/core/presentation-editor/tests/DragDropManager.test.ts index d99354a9a6..a5ac07dcfc 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/DragDropManager.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/DragDropManager.test.ts @@ -1,13 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { TextSelection } from 'prosemirror-state'; -import { DragDropManager, FIELD_ANNOTATION_DATA_TYPE, type DragDropDependencies } from '../input/DragDropManager.js'; +import { + DragDropManager, + FIELD_ANNOTATION_DATA_TYPE, + type DragDropDependencies, + getDropPayloadKind, + hasPossibleFiles, + getDroppedImageFiles, +} from '../input/DragDropManager.js'; // Mock TextSelection.create to avoid needing a real ProseMirror doc vi.spyOn(TextSelection, 'create').mockImplementation(() => { return { from: 50, to: 50 } as unknown as TextSelection; }); +// ============================================================================= +// Test Helpers +// ============================================================================= + /** * Creates a manual RAF scheduler for testing, allowing control over when * animation frame callbacks execute. @@ -39,9 +50,9 @@ function createManualRafScheduler(): { } /** - * Creates a mock DragEvent with the specified properties. + * Creates a mock DragEvent with field annotation data. */ -function createDragEvent( +function createFieldAnnotationDragEvent( type: string, options: { clientX?: number; @@ -58,7 +69,6 @@ function createDragEvent( clientY, }) as DragEvent; - // Mock dataTransfer const mockDataTransfer: Partial = { types: [FIELD_ANNOTATION_DATA_TYPE], getData: vi.fn((mimeType: string) => { @@ -83,28 +93,207 @@ function createDragEvent( return event; } -describe('DragDropManager - RAF Coalescing', () => { +/** + * Creates a mock DragEvent with image files. + */ +function createImageDragEvent( + type: string, + options: { + clientX?: number; + clientY?: number; + files?: File[]; + } = {}, +): DragEvent { + const { clientX = 100, clientY = 200 } = options; + const files = options.files ?? [new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' })]; + + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + }) as DragEvent; + + const fileList = { + length: files.length, + item: (i: number) => files[i] ?? null, + [Symbol.iterator]: function* () { + for (let i = 0; i < files.length; i++) yield files[i]; + }, + } as unknown as FileList; + + // Index files for array-style access + files.forEach((f, i) => { + (fileList as Record)[i] = f; + }); + + const mockDataTransfer: Partial = { + types: ['Files'], + files: fileList, + getData: vi.fn(() => ''), + setData: vi.fn(), + dropEffect: 'none' as DataTransferDropEffect, + effectAllowed: 'all' as DataTransferEffectAllowed, + }; + + Object.defineProperty(event, 'dataTransfer', { + value: mockDataTransfer, + writable: false, + }); + + return event; +} + +/** + * Creates a mock DragEvent with no recognized payload. + */ +function createEmptyDragEvent(type: string): DragEvent { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + }) as DragEvent; + + Object.defineProperty(event, 'dataTransfer', { + value: { + types: [], + files: { length: 0, item: () => null }, + getData: () => '', + setData: vi.fn(), + dropEffect: 'none' as DataTransferDropEffect, + effectAllowed: 'all' as DataTransferEffectAllowed, + }, + writable: false, + }); + + return event; +} + +// ============================================================================= +// Payload Classification Tests +// ============================================================================= + +describe('Payload classification helpers', () => { + describe('getDropPayloadKind', () => { + it('returns "fieldAnnotation" for field annotation payloads', () => { + const event = createFieldAnnotationDragEvent('dragover'); + expect(getDropPayloadKind(event)).toBe('fieldAnnotation'); + }); + + it('returns "imageFiles" for image file payloads', () => { + const event = createImageDragEvent('dragover'); + expect(getDropPayloadKind(event)).toBe('imageFiles'); + }); + + it('returns "none" for unsupported payloads', () => { + const event = createEmptyDragEvent('dragover'); + expect(getDropPayloadKind(event)).toBe('none'); + }); + + it('returns "fieldAnnotation" for mixed payloads (field annotation takes precedence)', () => { + const event = createFieldAnnotationDragEvent('dragover'); + + // Add image files to the same event + const files = [new File([new Uint8Array([1])], 'img.png', { type: 'image/png' })]; + const fileList = { length: 1, item: (i: number) => files[i], 0: files[0] } as unknown as FileList; + Object.defineProperty(event.dataTransfer, 'files', { value: fileList }); + + expect(getDropPayloadKind(event)).toBe('fieldAnnotation'); + }); + }); + + describe('hasPossibleFiles', () => { + it('returns true when dataTransfer.types includes "Files"', () => { + const event = createImageDragEvent('dragover'); + expect(hasPossibleFiles(event)).toBe(true); + }); + + it('returns true for non-image files (cannot distinguish during dragover)', () => { + const files = [new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' })]; + const event = createImageDragEvent('dragover', { files }); + // types still contains "Files" — hasPossibleFiles cannot inspect file types + expect(hasPossibleFiles(event)).toBe(true); + }); + + it('returns false when dataTransfer has no files', () => { + const event = createEmptyDragEvent('dragover'); + expect(hasPossibleFiles(event)).toBe(false); + }); + }); + + describe('getDroppedImageFiles', () => { + it('extracts only image files from dataTransfer', () => { + const imageFile = new File([new Uint8Array([1])], 'photo.png', { type: 'image/png' }); + const pdfFile = new File([new Uint8Array([2])], 'doc.pdf', { type: 'application/pdf' }); + const jpgFile = new File([new Uint8Array([3])], 'pic.jpg', { type: 'image/jpeg' }); + + const event = createImageDragEvent('drop', { files: [imageFile, pdfFile, jpgFile] }); + const result = getDroppedImageFiles(event); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('photo.png'); + expect(result[1].name).toBe('pic.jpg'); + }); + + it('returns empty array for events with no dataTransfer', () => { + const event = new MouseEvent('drop') as DragEvent; + expect(getDroppedImageFiles(event)).toEqual([]); + }); + + it('accepts image files with empty MIME type when extension is a known image format', () => { + const emptyMime = new File([new Uint8Array([1])], 'screenshot.png', { type: '' }); + const jpgEmpty = new File([new Uint8Array([2])], 'photo.JPG', { type: '' }); + const txtEmpty = new File([new Uint8Array([3])], 'notes.txt', { type: '' }); + const noExt = new File([new Uint8Array([4])], 'noext', { type: '' }); + + const event = createImageDragEvent('drop', { files: [emptyMime, jpgEmpty, txtEmpty, noExt] }); + const result = getDroppedImageFiles(event); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('screenshot.png'); + expect(result[1].name).toBe('photo.JPG'); + }); + + it('does not use extension fallback when MIME type is a non-image type', () => { + const pdfWithImageExt = new File([new Uint8Array([1])], 'trick.png', { type: 'application/pdf' }); + + const event = createImageDragEvent('drop', { files: [pdfWithImageExt] }); + const result = getDroppedImageFiles(event); + + expect(result).toHaveLength(0); + }); + }); +}); + +// ============================================================================= +// DragDropManager Tests +// ============================================================================= + +describe('DragDropManager', () => { let manager: DragDropManager; let viewportHost: HTMLElement; let painterHost: HTMLElement; let rafScheduler: ReturnType; let mockEditor: { isEditable: boolean; + options: Record; state: { - doc: { content: { size: number } }; + doc: { content: { size: number }; nodeAt: Mock }; tr: { setSelection: Mock; setMeta: Mock }; selection: { from: number; to: number }; }; view: { dispatch: Mock; dom: HTMLElement; focus: Mock }; emit: Mock; commands: { addFieldAnnotation: Mock }; + getMaxContentSize: Mock; }; let mockDeps: DragDropDependencies; let hitTestMock: Mock; let scheduleSelectionUpdateMock: Mock; + let insertImageFileMock: Mock; beforeEach(() => { - // Create DOM elements viewportHost = document.createElement('div'); viewportHost.className = 'viewport-host'; painterHost = document.createElement('div'); @@ -112,10 +301,8 @@ describe('DragDropManager - RAF Coalescing', () => { document.body.appendChild(viewportHost); document.body.appendChild(painterHost); - // Create RAF scheduler rafScheduler = createManualRafScheduler(); - // Mock window RAF on the document's defaultView Object.defineProperty(viewportHost.ownerDocument.defaultView, 'requestAnimationFrame', { value: rafScheduler.requestAnimationFrame, writable: true, @@ -127,15 +314,15 @@ describe('DragDropManager - RAF Coalescing', () => { configurable: true, }); - // Create mock editor const mockTr = { setSelection: vi.fn().mockReturnThis(), setMeta: vi.fn().mockReturnThis(), }; mockEditor = { isEditable: true, + options: {}, state: { - doc: { content: { size: 100 } }, + doc: { content: { size: 100 }, nodeAt: vi.fn() }, tr: mockTr, selection: { from: 0, to: 0 }, }, @@ -148,11 +335,12 @@ describe('DragDropManager - RAF Coalescing', () => { commands: { addFieldAnnotation: vi.fn(), }, + getMaxContentSize: vi.fn(() => ({ width: 800, height: 600 })), }; - // Create mock dependencies hitTestMock = vi.fn(() => ({ pos: 50 })); scheduleSelectionUpdateMock = vi.fn(); + insertImageFileMock = vi.fn().mockResolvedValue('success'); mockDeps = { getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), @@ -160,9 +348,9 @@ describe('DragDropManager - RAF Coalescing', () => { scheduleSelectionUpdate: scheduleSelectionUpdateMock, getViewportHost: vi.fn(() => viewportHost), getPainterHost: vi.fn(() => painterHost), + insertImageFile: insertImageFileMock, }; - // Initialize manager manager = new DragDropManager(); manager.setDependencies(mockDeps); manager.bind(); @@ -174,9 +362,13 @@ describe('DragDropManager - RAF Coalescing', () => { vi.clearAllMocks(); }); - describe('dragover coalescing', () => { + // ========================================================================== + // Field Annotation Dragover (existing behavior preserved) + // ========================================================================== + + describe('field annotation dragover coalescing', () => { it('should schedule RAF on first dragover event', () => { - const event = createDragEvent('dragover', { clientX: 100, clientY: 200 }); + const event = createFieldAnnotationDragEvent('dragover', { clientX: 100, clientY: 200 }); viewportHost.dispatchEvent(event); expect(rafScheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); @@ -184,204 +376,451 @@ describe('DragDropManager - RAF Coalescing', () => { }); it('should coalesce multiple dragover events into single RAF callback', () => { - // Dispatch multiple dragover events rapidly - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 150, clientY: 250 })); - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 200, clientY: 300 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 150, clientY: 250 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 200, clientY: 300 })); - // Should only schedule one RAF expect(rafScheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); }); it('should use the latest coordinates when RAF fires', () => { - // Dispatch multiple dragover events with different coordinates - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 150, clientY: 250 })); - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 200, clientY: 300 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 150, clientY: 250 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 200, clientY: 300 })); - // Flush the RAF rafScheduler.flush(); - // hitTest should be called with the LAST coordinates expect(hitTestMock).toHaveBeenCalledWith(200, 300); }); it('should update selection when RAF fires', () => { - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 100, clientY: 200 })); - // Before RAF fires, no selection update expect(mockEditor.view.dispatch).not.toHaveBeenCalled(); - // Flush the RAF rafScheduler.flush(); - // Now selection should be updated expect(mockEditor.state.tr.setSelection).toHaveBeenCalled(); expect(mockEditor.view.dispatch).toHaveBeenCalled(); expect(scheduleSelectionUpdateMock).toHaveBeenCalled(); }); it('should allow scheduling new RAF after previous one fires', () => { - // First dragover - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 100, clientY: 200 })); expect(rafScheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); - // Flush first RAF rafScheduler.flush(); expect(rafScheduler.hasPending()).toBe(false); - // Second dragover should schedule new RAF - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 150, clientY: 250 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 150, clientY: 250 })); expect(rafScheduler.requestAnimationFrame).toHaveBeenCalledTimes(2); }); }); - describe('drop cancels pending RAF', () => { - it('should cancel pending dragover RAF when drop occurs', () => { - // Schedule a dragover RAF - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + // ========================================================================== + // Image Dragover + // ========================================================================== + + describe('image dragover', () => { + it('should prevent default for image file payloads', () => { + const event = createImageDragEvent('dragover'); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + viewportHost.dispatchEvent(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should set dropEffect to "copy" for image files', () => { + const event = createImageDragEvent('dragover'); + viewportHost.dispatchEvent(event); + + expect(event.dataTransfer!.dropEffect).toBe('copy'); + }); + + it('should schedule RAF-coalesced selection update during dragover', () => { + viewportHost.dispatchEvent(createImageDragEvent('dragover', { clientX: 120, clientY: 220 })); + + expect(rafScheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); + + rafScheduler.flush(); + + expect(hitTestMock).toHaveBeenCalledWith(120, 220); + }); + + it('should not schedule RAF when editor is not editable', () => { + mockEditor.isEditable = false; + + viewportHost.dispatchEvent(createImageDragEvent('dragover')); + + expect(rafScheduler.requestAnimationFrame).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Image Drop + // ========================================================================== + + describe('image drop', () => { + it('should call insertImageFile for each dropped image', async () => { + const file1 = new File([new Uint8Array([1])], 'photo1.png', { type: 'image/png' }); + const file2 = new File([new Uint8Array([2])], 'photo2.jpg', { type: 'image/jpeg' }); + const event = createImageDragEvent('drop', { files: [file1, file2] }); + + viewportHost.dispatchEvent(event); + + // insertImageFile is async; wait for it + await vi.waitFor(() => { + expect(insertImageFileMock).toHaveBeenCalledTimes(2); + }); + + expect(insertImageFileMock.mock.calls[0][0].file).toBe(file1); + expect(insertImageFileMock.mock.calls[1][0].file).toBe(file2); + }); + + it('should cancel pending RAF on drop', () => { + viewportHost.dispatchEvent(createImageDragEvent('dragover')); expect(rafScheduler.hasPending()).toBe(true); - // Now drop - viewportHost.dispatchEvent(createDragEvent('drop', { clientX: 150, clientY: 250 })); + viewportHost.dispatchEvent(createImageDragEvent('drop')); - // RAF should be cancelled expect(rafScheduler.cancelAnimationFrame).toHaveBeenCalled(); }); - it('should not apply stale dragover selection after drop', () => { - // Simulate the race condition scenario: - // 1. Dragover schedules RAF with position A - // 2. Drop sets selection to position B - // 3. RAF fires (if not cancelled) would overwrite to position A + it('should resolve drop position via hitTest', async () => { + hitTestMock.mockReturnValue({ pos: 42 }); - // Dragover schedules RAF - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + const event = createImageDragEvent('drop', { clientX: 300, clientY: 400 }); + viewportHost.dispatchEvent(event); - // Clear mock to track only calls after this point - hitTestMock.mockClear(); + await vi.waitFor(() => { + expect(insertImageFileMock).toHaveBeenCalledTimes(1); + }); - // Drop occurs before RAF fires - this calls hitTest for the drop position - viewportHost.dispatchEvent(createDragEvent('drop', { clientX: 150, clientY: 250 })); + expect(hitTestMock).toHaveBeenCalledWith(300, 400); + }); - // At this point hitTest was called once for the drop - const callsAfterDrop = hitTestMock.mock.calls.length; + it('should not insert when editor is not editable', async () => { + mockEditor.isEditable = false; - // Try to flush - should do nothing since RAF was cancelled - rafScheduler.flush(); + viewportHost.dispatchEvent(createImageDragEvent('drop')); - // No additional hitTest calls should have occurred (the stale dragover RAF was cancelled) - expect(hitTestMock.mock.calls.length).toBe(callsAfterDrop); + // Give async code a chance to run + await new Promise((r) => setTimeout(r, 10)); + expect(insertImageFileMock).not.toHaveBeenCalled(); }); - it('should handle drop gracefully when no pending RAF exists', () => { - // Drop without prior dragover - should not throw - expect(() => { - viewportHost.dispatchEvent(createDragEvent('drop', { clientX: 150, clientY: 250 })); - }).not.toThrow(); + it('should handle empty files gracefully', async () => { + const event = createImageDragEvent('drop', { files: [] }); + viewportHost.dispatchEvent(event); + + // No files to process, so insertImageFile should not be called + await new Promise((r) => setTimeout(r, 10)); + expect(insertImageFileMock).not.toHaveBeenCalled(); + }); + + it('should not move caret when dropped files contain no images', async () => { + const pdfFile = new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }); + const event = createImageDragEvent('drop', { files: [pdfFile] }); + + viewportHost.dispatchEvent(event); + + await new Promise((r) => setTimeout(r, 10)); + + // Selection should NOT have been changed — no image means no state mutation + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(insertImageFileMock).not.toHaveBeenCalled(); + }); + + it('should focus editor and schedule selection update after drop', async () => { + viewportHost.dispatchEvent(createImageDragEvent('drop')); + + await vi.waitFor(() => { + expect(insertImageFileMock).toHaveBeenCalled(); + }); + + expect(mockEditor.view.focus).toHaveBeenCalled(); + expect(scheduleSelectionUpdateMock).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // hitTest Failure Fallback + // ========================================================================== + + describe('hitTest failure fallback', () => { + it('should fall back to current PM selection when hitTest returns null', async () => { + hitTestMock.mockReturnValue(null); + mockEditor.state.selection = { from: 25, to: 25 }; + + viewportHost.dispatchEvent(createImageDragEvent('drop')); + + await vi.waitFor(() => { + expect(insertImageFileMock).toHaveBeenCalledTimes(1); + }); + + // Selection should have been set (proving fallback position was used) + expect(mockEditor.state.tr.setSelection).toHaveBeenCalled(); + }); + + it('should fall back to document end when both hitTest and selection are unavailable', async () => { + hitTestMock.mockReturnValue(null); + // Set selection.from to null-ish by setting it to a valid number. + // The real fallback chain is hitTest?.pos → selection?.from → doc.content.size + // We test document end by checking that selection is set even with null hitTest. + mockEditor.state.selection = { from: 0, to: 0 }; + + viewportHost.dispatchEvent(createImageDragEvent('drop')); + + await vi.waitFor(() => { + expect(insertImageFileMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + // ========================================================================== + // Multi-image Drop Ordering + // ========================================================================== + + describe('multi-image drop ordering', () => { + it('should process images sequentially (deterministic order)', async () => { + const callOrder: string[] = []; + + insertImageFileMock.mockImplementation(async ({ file }: { file: File }) => { + callOrder.push(file.name); + return 'success'; + }); + + const files = [ + new File([new Uint8Array([1])], 'first.png', { type: 'image/png' }), + new File([new Uint8Array([2])], 'second.png', { type: 'image/png' }), + new File([new Uint8Array([3])], 'third.png', { type: 'image/png' }), + ]; - // cancelAnimationFrame might still be called but with no effect - // The important thing is no error occurred + viewportHost.dispatchEvent(createImageDragEvent('drop', { files })); + + await vi.waitFor(() => { + expect(insertImageFileMock).toHaveBeenCalledTimes(3); + }); + + expect(callOrder).toEqual(['first.png', 'second.png', 'third.png']); }); }); - describe('dragEnd cancels pending RAF', () => { - it('should cancel pending dragover RAF when drag ends', () => { - // Schedule a dragover RAF - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + // ========================================================================== + // Drag Cancellation Cleanup + // ========================================================================== + + describe('drag cancellation cleanup', () => { + it('should cancel pending RAF on dragend', () => { + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); expect(rafScheduler.hasPending()).toBe(true); - // End the drag (e.g., user cancelled or dropped outside) - painterHost.dispatchEvent(createDragEvent('dragend')); + painterHost.dispatchEvent(createFieldAnnotationDragEvent('dragend')); - // RAF should be cancelled expect(rafScheduler.cancelAnimationFrame).toHaveBeenCalled(); }); it('should not apply stale selection after drag ends', () => { hitTestMock.mockReturnValueOnce({ pos: 10 }); - // Dragover schedules RAF - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); + painterHost.dispatchEvent(createFieldAnnotationDragEvent('dragend')); - // Drag ends (e.g., user releases outside drop zone) - painterHost.dispatchEvent(createDragEvent('dragend')); - - // Try to flush - should do nothing since cancelled rafScheduler.flush(); - // hitTest should not have been called since RAF was cancelled expect(hitTestMock).not.toHaveBeenCalled(); }); + + it('should cancel pending RAF on dragleave with null relatedTarget', () => { + viewportHost.dispatchEvent(createImageDragEvent('dragover')); + expect(rafScheduler.hasPending()).toBe(true); + + const leaveEvent = new MouseEvent('dragleave', { + bubbles: true, + cancelable: true, + relatedTarget: null, + }) as DragEvent; + viewportHost.dispatchEvent(leaveEvent); + + expect(rafScheduler.cancelAnimationFrame).toHaveBeenCalled(); + }); + + it('should NOT cancel pending RAF on dragleave with internal relatedTarget', () => { + const innerChild = document.createElement('span'); + viewportHost.appendChild(innerChild); + + viewportHost.dispatchEvent(createImageDragEvent('dragover')); + expect(rafScheduler.hasPending()).toBe(true); + + rafScheduler.cancelAnimationFrame.mockClear(); + + const leaveEvent = new MouseEvent('dragleave', { + bubbles: true, + cancelable: true, + relatedTarget: innerChild, + }) as DragEvent; + viewportHost.dispatchEvent(leaveEvent); + + expect(rafScheduler.cancelAnimationFrame).not.toHaveBeenCalled(); + }); }); + // ========================================================================== + // Window-level Fallback + // ========================================================================== + + describe('window-level fallback', () => { + it('should route image drops on overlay targets through handleDrop', async () => { + const overlay = document.createElement('div'); + document.body.appendChild(overlay); + + const event = createImageDragEvent('drop'); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + Object.defineProperty(event, 'target', { value: overlay }); + window.dispatchEvent(event); + + await vi.waitFor(() => { + expect(insertImageFileMock).toHaveBeenCalledTimes(1); + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should preventDefault on image dragover on overlay targets', () => { + const overlay = document.createElement('div'); + document.body.appendChild(overlay); + + const event = createImageDragEvent('dragover'); + Object.defineProperty(event, 'target', { value: overlay }); + + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + window.dispatchEvent(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(event.dataTransfer!.dropEffect).toBe('copy'); + }); + + it('should handle field annotation drops on overlay targets (existing behavior)', () => { + const overlay = document.createElement('div'); + document.body.appendChild(overlay); + + const event = createFieldAnnotationDragEvent('dragover'); + Object.defineProperty(event, 'target', { value: overlay }); + + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + window.dispatchEvent(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Unrecognized Payloads + // ========================================================================== + + describe('unrecognized payloads', () => { + it('should not schedule RAF for dragover with no recognized payload', () => { + const event = createEmptyDragEvent('dragover'); + viewportHost.dispatchEvent(event); + + expect(rafScheduler.requestAnimationFrame).not.toHaveBeenCalled(); + }); + + it('should not handle drop with no recognized payload', () => { + const event = createEmptyDragEvent('drop'); + viewportHost.dispatchEvent(event); + + expect(insertImageFileMock).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Drop Cancels Pending RAF (existing behavior preserved) + // ========================================================================== + + describe('drop cancels pending RAF', () => { + it('should cancel pending dragover RAF when drop occurs', () => { + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); + expect(rafScheduler.hasPending()).toBe(true); + + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('drop')); + + expect(rafScheduler.cancelAnimationFrame).toHaveBeenCalled(); + }); + + it('should not apply stale dragover selection after drop', () => { + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); + + hitTestMock.mockClear(); + + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('drop')); + + const callsAfterDrop = hitTestMock.mock.calls.length; + + rafScheduler.flush(); + + expect(hitTestMock.mock.calls.length).toBe(callsAfterDrop); + }); + + it('should handle drop gracefully when no pending RAF exists', () => { + expect(() => { + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('drop')); + }).not.toThrow(); + }); + }); + + // ========================================================================== + // Destroy + // ========================================================================== + describe('destroy cancels pending RAF', () => { it('should cancel pending dragover RAF on destroy', () => { - // Schedule a dragover RAF - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); expect(rafScheduler.hasPending()).toBe(true); - // Destroy the manager manager.destroy(); - // RAF should be cancelled expect(rafScheduler.cancelAnimationFrame).toHaveBeenCalled(); }); }); + // ========================================================================== + // Edge Cases + // ========================================================================== + describe('edge cases', () => { it('should not schedule RAF when editor is not editable', () => { mockEditor.isEditable = false; - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); expect(rafScheduler.requestAnimationFrame).not.toHaveBeenCalled(); }); - it('should not schedule RAF when event has no field annotation data', () => { - const event = new MouseEvent('dragover', { - bubbles: true, - cancelable: true, - clientX: 100, - clientY: 200, - }) as DragEvent; - - // No dataTransfer = no field annotation data - Object.defineProperty(event, 'dataTransfer', { - value: { types: [], getData: () => '' }, - writable: false, - }); - + it('should not schedule RAF when event has no recognized data', () => { + const event = createEmptyDragEvent('dragover'); viewportHost.dispatchEvent(event); expect(rafScheduler.requestAnimationFrame).not.toHaveBeenCalled(); }); it('should handle RAF callback when deps become null', () => { - // Schedule RAF - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); - // Simulate deps being cleared (edge case during teardown) manager.destroy(); - // Manually invoke the callback that was scheduled (simulating race) - // This shouldn't throw expect(() => rafScheduler.flush()).not.toThrow(); }); it('should skip selection update if position unchanged', () => { - // Set current selection to match where hitTest will return hitTestMock.mockReturnValue({ pos: 50 }); - // Mock the selection to appear as a TextSelection at pos 50 - // The actual code checks instanceof TextSelection, but our mock won't pass that check - // so it will always update. We just verify the basic flow works. mockEditor.state.selection = { from: 50, to: 50 } as unknown as typeof mockEditor.state.selection; - viewportHost.dispatchEvent(createDragEvent('dragover', { clientX: 100, clientY: 200 })); + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); rafScheduler.flush(); - // Verify the dragover flow executed (hitTest was called) expect(hitTestMock).toHaveBeenCalled(); }); }); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/index.js b/packages/super-editor/src/extensions/image/imageHelpers/index.js index 83856e9c32..6befae2d7a 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/index.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/index.js @@ -7,3 +7,4 @@ export * from './imagePositionPlugin.js'; export * from './fileNameUtils.js'; export * from './rotation.js'; export * from './legacyAttributes.js'; +export * from './processAndInsertImageFile.js'; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/processAndInsertImageFile.js b/packages/super-editor/src/extensions/image/imageHelpers/processAndInsertImageFile.js new file mode 100644 index 0000000000..968bec941f --- /dev/null +++ b/packages/super-editor/src/extensions/image/imageHelpers/processAndInsertImageFile.js @@ -0,0 +1,43 @@ +import { + checkAndProcessImage, + replaceSelectionWithImagePlaceholder, + uploadAndInsertImage, +} from './startImageUpload.js'; + +/** + * @typedef {'success' | 'skipped'} ProcessAndInsertResult + */ + +/** + * Processes a single image file and inserts it into the editor. + * + * Encapsulates the full 3-step image insertion pipeline: + * 1. Validate and resize the file + * 2. Insert a placeholder at the current selection + * 3. Upload and swap the placeholder for the final image node + * + * Throws on failure — callers are responsible for error handling. + * + * @param {object} params + * @param {File} params.file - The image file to process and insert. + * @param {object} params.editor - The ProseMirror editor instance. + * @param {object} params.view - The ProseMirror editor view. + * @param {object} params.editorOptions - Editor options (for header/footer selection handling). + * @param {() => { width?: number; height?: number }} params.getMaxContentSize - Returns max content dimensions. + * @returns {Promise<'success' | 'skipped'>} + */ +export async function processAndInsertImageFile({ file, editor, view, editorOptions, getMaxContentSize }) { + const { size, file: processedFile } = await checkAndProcessImage({ file, getMaxContentSize }); + + if (!processedFile) { + return 'skipped'; + } + + const id = {}; + + replaceSelectionWithImagePlaceholder({ view, editorOptions, id }); + + await uploadAndInsertImage({ editor, view, file: processedFile, size, id }); + + return 'success'; +} diff --git a/packages/super-editor/src/extensions/image/imageHelpers/processAndInsertImageFile.test.js b/packages/super-editor/src/extensions/image/imageHelpers/processAndInsertImageFile.test.js new file mode 100644 index 0000000000..c0d4ab892c --- /dev/null +++ b/packages/super-editor/src/extensions/image/imageHelpers/processAndInsertImageFile.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { processAndInsertImageFile } from './processAndInsertImageFile.js'; +import * as startImageUpload from './startImageUpload.js'; + +describe('processAndInsertImageFile', () => { + let checkAndProcessImageSpy; + let replaceSelectionWithImagePlaceholderSpy; + let uploadAndInsertImageSpy; + + const createTestFile = (name = 'test.png') => new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' }); + + const mockParams = () => ({ + file: createTestFile(), + editor: { options: {}, view: {} }, + view: { state: { tr: {} } }, + editorOptions: {}, + getMaxContentSize: () => ({ width: 800, height: 600 }), + }); + + beforeEach(() => { + checkAndProcessImageSpy = vi.spyOn(startImageUpload, 'checkAndProcessImage'); + replaceSelectionWithImagePlaceholderSpy = vi.spyOn(startImageUpload, 'replaceSelectionWithImagePlaceholder'); + uploadAndInsertImageSpy = vi.spyOn(startImageUpload, 'uploadAndInsertImage'); + + replaceSelectionWithImagePlaceholderSpy.mockImplementation(() => {}); + uploadAndInsertImageSpy.mockResolvedValue(undefined); + }); + + it('returns "success" when the full pipeline completes', async () => { + const processedFile = createTestFile('processed.png'); + checkAndProcessImageSpy.mockResolvedValue({ + file: processedFile, + size: { width: 100, height: 100 }, + }); + + const params = mockParams(); + const result = await processAndInsertImageFile(params); + + expect(result).toBe('success'); + expect(checkAndProcessImageSpy).toHaveBeenCalledWith({ + file: params.file, + getMaxContentSize: params.getMaxContentSize, + }); + expect(replaceSelectionWithImagePlaceholderSpy).toHaveBeenCalledWith({ + view: params.view, + editorOptions: params.editorOptions, + id: expect.any(Object), + }); + expect(uploadAndInsertImageSpy).toHaveBeenCalledWith({ + editor: params.editor, + view: params.view, + file: processedFile, + size: { width: 100, height: 100 }, + id: expect.any(Object), + }); + }); + + it('returns "skipped" when checkAndProcessImage returns a null file', async () => { + checkAndProcessImageSpy.mockResolvedValue({ + file: null, + size: { width: 0, height: 0 }, + }); + + const result = await processAndInsertImageFile(mockParams()); + + expect(result).toBe('skipped'); + expect(replaceSelectionWithImagePlaceholderSpy).not.toHaveBeenCalled(); + expect(uploadAndInsertImageSpy).not.toHaveBeenCalled(); + }); + + it('throws when checkAndProcessImage throws', async () => { + checkAndProcessImageSpy.mockRejectedValue(new Error('processing failed')); + + await expect(processAndInsertImageFile(mockParams())).rejects.toThrow('processing failed'); + }); + + it('throws when uploadAndInsertImage throws', async () => { + checkAndProcessImageSpy.mockResolvedValue({ + file: createTestFile(), + size: { width: 100, height: 100 }, + }); + uploadAndInsertImageSpy.mockRejectedValue(new Error('upload failed')); + + await expect(processAndInsertImageFile(mockParams())).rejects.toThrow('upload failed'); + }); + + it('uses the same placeholder id for both replace and upload steps', async () => { + const processedFile = createTestFile(); + checkAndProcessImageSpy.mockResolvedValue({ + file: processedFile, + size: { width: 50, height: 50 }, + }); + + await processAndInsertImageFile(mockParams()); + + const replaceId = replaceSelectionWithImagePlaceholderSpy.mock.calls[0][0].id; + const uploadId = uploadAndInsertImageSpy.mock.calls[0][0].id; + expect(replaceId).toBe(uploadId); + }); +}); diff --git a/tests/behavior/tests/basic-commands/drag-drop-image-insertion.spec.ts b/tests/behavior/tests/basic-commands/drag-drop-image-insertion.spec.ts new file mode 100644 index 0000000000..0cd29a1fb7 --- /dev/null +++ b/tests/behavior/tests/basic-commands/drag-drop-image-insertion.spec.ts @@ -0,0 +1,255 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import type { Locator } from '@playwright/test'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +type PlacementSnapshot = { + imagePos: number; + imageCount: number; +}; + +type DropDiagnostics = { + dragOverPrevented: boolean; + dropPrevented: boolean; + droppedFileCount: number; +}; + +async function getImagePlacementSnapshot(superdoc: SuperDocFixture): Promise { + return superdoc.page.evaluate(() => { + const editor = (window as any).editor; + const doc = editor?.state?.doc; + if (!doc) { + throw new Error('Editor document is unavailable.'); + } + + let imagePos = -1; + let imageCount = 0; + + doc.descendants((node: any, pos: number) => { + if (node.type?.name === 'image') { + imageCount += 1; + if (imagePos === -1) imagePos = pos; + } + }); + + return { imagePos, imageCount }; + }); +} + +async function getDropTarget(superdoc: SuperDocFixture): Promise { + const viewport = superdoc.page.locator('.presentation-editor__viewport').first(); + if ((await viewport.count()) > 0) { + return viewport; + } + return superdoc.page.locator('#editor').first(); +} + +async function dispatchImageDropAtPos( + superdoc: SuperDocFixture, + clientX: number, + clientY: number, + fileName: string, + imageHeightPx: number, +): Promise { + const target = await getDropTarget(superdoc); + const targetSelector = (await target.getAttribute('class'))?.includes('presentation-editor__viewport') + ? '.presentation-editor__viewport' + : '#editor'; + + const diagnostics = await superdoc.page.evaluate( + async ({ selector, dropX, dropY, name, imageHeight }) => { + const host = document.querySelector(selector); + if (!host) { + throw new Error(`Unable to attach drag diagnostics. Missing target selector: ${selector}`); + } + + const dt = new DataTransfer(); + const canvas = document.createElement('canvas'); + canvas.width = 8; + canvas.height = imageHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get 2D canvas context for drop image generation.'); + } + ctx.fillStyle = '#ff0000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) { + throw new Error('Failed to generate drop image blob.'); + } + + const file = new File([blob], name, { type: 'image/png' }); + dt.items.add(file); + dt.effectAllowed = 'copy'; + + (window as any).__sdDragDropDiag = {}; + + const dragOverListener = (event: Event) => { + const dragEvent = event as DragEvent; + (window as any).__sdDragDropDiag = { + ...(window as any).__sdDragDropDiag, + dragOverPrevented: dragEvent.defaultPrevented, + }; + }; + + const dropListener = (event: Event) => { + const dragEvent = event as DragEvent; + (window as any).__sdDragDropDiag = { + ...(window as any).__sdDragDropDiag, + dropPrevented: dragEvent.defaultPrevented, + droppedFileCount: dragEvent.dataTransfer?.files?.length ?? 0, + }; + }; + + host.addEventListener('dragover', dragOverListener, { once: true }); + host.addEventListener('drop', dropListener, { once: true }); + + const dragOverEvent = new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientX: dropX, + clientY: dropY, + }); + host.dispatchEvent(dragOverEvent); + + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientX: dropX, + clientY: dropY, + }); + host.dispatchEvent(dropEvent); + + const result = (window as any).__sdDragDropDiag ?? {}; + return { + dragOverPrevented: Boolean(result.dragOverPrevented), + dropPrevented: Boolean(result.dropPrevented), + droppedFileCount: Number.isFinite(result.droppedFileCount) ? result.droppedFileCount : 0, + }; + }, + { selector: targetSelector, dropX: clientX, dropY: clientY, name: fileName, imageHeight: imageHeightPx }, + ); + + return diagnostics; +} + +async function getLineTopByText(superdoc: SuperDocFixture, text: string): Promise { + return superdoc.page.evaluate((targetText) => { + const lines = Array.from(document.querySelectorAll('.superdoc-line')) as HTMLElement[]; + const targetLine = lines.find((line) => (line.textContent ?? '').includes(targetText)); + if (!targetLine) { + throw new Error(`Unable to find rendered line containing "${targetText}".`); + } + return targetLine.getBoundingClientRect().top; + }, text); +} + +async function getRenderedTextMidpoint( + superdoc: SuperDocFixture, + text: string, +): Promise<{ clientX: number; clientY: number }> { + return superdoc.page.evaluate((targetText) => { + const viewport = document.querySelector('.presentation-editor__viewport'); + if (!viewport) { + throw new Error('Unable to locate presentation viewport.'); + } + + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT); + let current: Node | null = walker.nextNode(); + while (current) { + const textValue = current.textContent ?? ''; + const hitIndex = textValue.indexOf(targetText); + if (hitIndex >= 0) { + const range = document.createRange(); + range.setStart(current, hitIndex); + range.setEnd(current, hitIndex + targetText.length); + const rect = range.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return { + clientX: Math.round(rect.left + Math.min(6, rect.width / 2)), + clientY: Math.round(rect.top + rect.height / 2), + }; + } + } + current = walker.nextNode(); + } + + throw new Error(`Could not resolve rendered text position for "${targetText}".`); + }, text); +} + +async function getTextPosOrNull(superdoc: SuperDocFixture, text: string): Promise { + return superdoc.page.evaluate((targetText) => { + const doc = (window as any).editor?.state?.doc; + if (!doc) { + throw new Error('Editor document is unavailable.'); + } + + let found: number | null = null; + doc.descendants((node: any, pos: number) => { + if (found != null) return false; + if (!node.isText || !node.text) return; + const hit = node.text.indexOf(targetText); + if (hit >= 0) { + found = pos + hit; + return false; + } + }); + return found; + }, text); +} + +test('drops an image before target text at the requested location', async ({ superdoc, browserName }) => { + test.skip(browserName !== 'chromium', 'Synthetic file DataTransfer drag/drop is deterministic in Chromium only.'); + + await superdoc.type('alpha beta'); + await superdoc.newLine(); + await superdoc.type('omega'); + await superdoc.waitForStable(); + + const before = await getImagePlacementSnapshot(superdoc); + expect(before.imageCount).toBe(0); + const omegaLineTopBeforeDrop = await getLineTopByText(superdoc, 'omega'); + + const betaPosBeforeDrop = await superdoc.findTextPos('beta'); + await superdoc.setTextSelection(betaPosBeforeDrop, betaPosBeforeDrop); + await superdoc.waitForStable(); + const betaDropPoint = await getRenderedTextMidpoint(superdoc, 'beta'); + + const diagnostics = await dispatchImageDropAtPos( + superdoc, + betaDropPoint.clientX, + betaDropPoint.clientY, + 'drop-before-beta.png', + 160, + ); + expect(diagnostics.droppedFileCount).toBeGreaterThan(0); + const imageCountAfterDrop = await expect + .poll(async () => (await getImagePlacementSnapshot(superdoc)).imageCount, { + timeout: 15_000, + }) + .toBeGreaterThanOrEqual(0) + .then(async () => (await getImagePlacementSnapshot(superdoc)).imageCount); + test.skip( + imageCountAfterDrop === 0, + `Synthetic drop delivered files but inserted no image (dragOverPrevented=${diagnostics.dragOverPrevented}, dropPrevented=${diagnostics.dropPrevented}).`, + ); + expect(imageCountAfterDrop).toBe(1); + await superdoc.waitForStable(); + + const after = await getImagePlacementSnapshot(superdoc); + const alphaPos = await getTextPosOrNull(superdoc, 'alpha'); + const betaPosAfterDrop = await getTextPosOrNull(superdoc, 'beta'); + const omegaLineTopAfterDrop = await getLineTopByText(superdoc, 'omega'); + const textAfterDrop = await superdoc.getTextContent(); + + if (alphaPos == null || betaPosAfterDrop == null) { + throw new Error(`Expected post-drop text to preserve "alpha" and "beta". Actual text: "${textAfterDrop}"`); + } + expect(after.imagePos).toBeGreaterThan(alphaPos); + expect(after.imagePos).toBeLessThan(betaPosAfterDrop); + expect(omegaLineTopAfterDrop).toBeGreaterThan(omegaLineTopBeforeDrop); + expect(textAfterDrop).toContain('alpha beta omega'); +});