From 24e4bbb2b22cef71eb468dcfcc9df2ea871e4a1d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 16 Jan 2026 13:43:42 -0800 Subject: [PATCH 1/3] refactor(presentation-editor): consolidate input handling into dedicated managers --- .../layout-bridge/src/drag-handler.ts | 515 ------- .../layout-engine/layout-bridge/src/index.ts | 14 - .../layout-bridge/test/drag-handler.test.ts | 506 ------ .../painters/dom/src/link-click.test.ts | 190 +-- .../painters/dom/src/renderer.ts | 24 +- .../presentation-editor/PresentationEditor.ts | 1366 ++--------------- .../input/DragDropManager.ts | 522 +++++++ .../input/FieldAnnotationDragDrop.ts | 402 ----- ...rEventManager.ts => EditorInputManager.ts} | 113 +- .../pointer-events/index.ts | 6 +- 10 files changed, 679 insertions(+), 2979 deletions(-) delete mode 100644 packages/layout-engine/layout-bridge/src/drag-handler.ts delete mode 100644 packages/layout-engine/layout-bridge/test/drag-handler.test.ts create mode 100644 packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts delete mode 100644 packages/super-editor/src/core/presentation-editor/input/FieldAnnotationDragDrop.ts rename packages/super-editor/src/core/presentation-editor/pointer-events/{PointerEventManager.ts => EditorInputManager.ts} (92%) diff --git a/packages/layout-engine/layout-bridge/src/drag-handler.ts b/packages/layout-engine/layout-bridge/src/drag-handler.ts deleted file mode 100644 index cd2b5ebae..000000000 --- a/packages/layout-engine/layout-bridge/src/drag-handler.ts +++ /dev/null @@ -1,515 +0,0 @@ -/** - * Drag and drop handler for field annotations in the layout engine. - * - * This module provides drag-and-drop functionality for field annotation elements - * rendered by the DOM painter. It mirrors the behavior of super-editor's - * FieldAnnotationPlugin but for the layout engine's rendered DOM. - * - * @module drag-handler - */ - -import { clickToPositionDom } from './dom-mapping.js'; - -/** - * Data structure for field annotation drag operations. - * This matches the format used by super-editor's FieldAnnotationPlugin. - */ -export interface FieldAnnotationDragData { - /** Unique identifier for the field */ - fieldId?: string; - /** Type of field (e.g., 'TEXTINPUT', 'CHECKBOX') */ - fieldType?: string; - /** Variant of the field annotation (e.g., 'text', 'image', 'signature') */ - variant?: string; - /** Display label shown in the annotation */ - displayLabel?: string; - /** ProseMirror start position */ - pmStart?: number; - /** ProseMirror end position */ - pmEnd?: number; - /** Source element dataset attributes */ - attributes?: Record; -} - -/** - * Event emitted when a drag operation starts on a field annotation. - */ -export interface DragStartEvent { - /** The original DOM drag event */ - event: DragEvent; - /** The field annotation element being dragged */ - element: HTMLElement; - /** Extracted field annotation data */ - data: FieldAnnotationDragData; -} - -/** - * Event emitted when a field annotation is dropped. - */ -export interface DropEvent { - /** The original DOM drop event */ - event: DragEvent; - /** The field annotation data from the drag operation */ - data: FieldAnnotationDragData; - /** ProseMirror position where the drop occurred, or null if outside valid area */ - pmPosition: number | null; - /** Client X coordinate of the drop */ - clientX: number; - /** Client Y coordinate of the drop */ - clientY: number; -} - -/** - * Event emitted during drag over for visual feedback. - */ -export interface DragOverEvent { - /** The original DOM dragover event */ - event: DragEvent; - /** Client X coordinate */ - clientX: number; - /** Client Y coordinate */ - clientY: number; - /** Whether the drag contains field annotation data */ - hasFieldAnnotation: boolean; -} - -/** - * Callback type for drag start events. - */ -export type DragStartCallback = (event: DragStartEvent) => void; - -/** - * Callback type for drop events. - */ -export type DropCallback = (event: DropEvent) => void; - -/** - * Callback type for drag over events. - */ -export type DragOverCallback = (event: DragOverEvent) => void; - -/** - * Callback type for drag end events. - */ -export type DragEndCallback = (event: DragEvent) => void; - -/** - * Configuration options for the DragHandler. - */ -export interface DragHandlerConfig { - /** Callback fired when drag starts on a field annotation */ - onDragStart?: DragStartCallback; - /** Callback fired when a field annotation is dropped */ - onDrop?: DropCallback; - /** Callback fired during drag over for visual feedback */ - onDragOver?: DragOverCallback; - /** Callback fired when drag ends (regardless of drop success) */ - onDragEnd?: DragEndCallback; - /** MIME type for field annotation data in dataTransfer (default: 'application/x-field-annotation') */ - mimeType?: string; -} - -/** Default MIME type for field annotation drag data */ -const DEFAULT_MIME_TYPE = 'application/x-field-annotation'; - -/** Legacy MIME type for compatibility with super-editor */ -const LEGACY_MIME_TYPE = 'fieldAnnotation'; - -/** - * Safely parses an integer from a string, returning undefined if invalid. - * - * @param value - The string value to parse - * @returns Parsed integer or undefined if parsing fails or input is undefined - */ -function parseIntSafe(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; -} - -/** - * Extracts field annotation data from a draggable element's dataset. - * - * @param element - The DOM element with data attributes - * @returns Extracted field annotation data - */ -function extractFieldAnnotationData(element: HTMLElement): FieldAnnotationDragData { - const dataset = element.dataset; - - // Safely convert DOMStringMap to Record - const attributes: Record = {}; - for (const key in dataset) { - const value = dataset[key]; - if (value !== undefined) { - attributes[key] = value; - } - } - - return { - fieldId: dataset.fieldId, - fieldType: dataset.fieldType, - variant: dataset.variant ?? dataset.type, - displayLabel: dataset.displayLabel, - pmStart: parseIntSafe(dataset.pmStart), - pmEnd: parseIntSafe(dataset.pmEnd), - attributes, - }; -} - -/** - * Handles drag and drop operations for field annotations in the layout engine. - * - * This class sets up event listeners on a container element to handle: - * - dragstart: Captures field annotation data and sets drag image - * - dragover: Provides visual feedback during drag operations - * - drop: Maps drop coordinates to ProseMirror positions - * - dragend: Cleans up drag state - * - * @example - * ```typescript - * const container = document.querySelector('.superdoc-layout'); - * const handler = new DragHandler(container, { - * onDragStart: (e) => console.log('Drag started:', e.data), - * onDrop: (e) => { - * if (e.pmPosition !== null) { - * editor.commands.addFieldAnnotation(e.pmPosition, e.data.attributes); - * } - * }, - * }); - * - * // Later, when cleaning up: - * handler.destroy(); - * ``` - */ -export class DragHandler { - private container: HTMLElement; - private config: DragHandlerConfig; - private mimeType: string; - private boundHandlers: { - dragstart: (e: DragEvent) => void; - dragover: (e: DragEvent) => void; - drop: (e: DragEvent) => void; - dragend: (e: DragEvent) => void; - dragleave: (e: DragEvent) => void; - }; - private windowDragoverHandler: (e: DragEvent) => void; - private windowDropHandler: (e: DragEvent) => void; - - /** - * Creates a new DragHandler instance. - * - * @param container - The DOM container element (typically .superdoc-layout) - * @param config - Configuration options and callbacks - */ - constructor(container: HTMLElement, config: DragHandlerConfig = {}) { - this.container = container; - this.config = config; - this.mimeType = config.mimeType ?? DEFAULT_MIME_TYPE; - - // Bind handlers to preserve 'this' context - this.boundHandlers = { - dragstart: this.handleDragStart.bind(this), - dragover: this.handleDragOver.bind(this), - drop: this.handleDrop.bind(this), - dragend: this.handleDragEnd.bind(this), - dragleave: this.handleDragLeave.bind(this), - }; - - // Window-level handlers for overlay support - this.windowDragoverHandler = this.handleWindowDragOver.bind(this); - this.windowDropHandler = this.handleWindowDrop.bind(this); - - this.attachListeners(); - } - - /** - * Attaches event listeners to the container and window. - */ - private attachListeners(): void { - // Container-level listeners - this.container.addEventListener('dragstart', this.boundHandlers.dragstart); - this.container.addEventListener('dragover', this.boundHandlers.dragover); - this.container.addEventListener('drop', this.boundHandlers.drop); - this.container.addEventListener('dragend', this.boundHandlers.dragend); - this.container.addEventListener('dragleave', this.boundHandlers.dragleave); - - // Window-level listeners to handle drops on overlays (selection highlights, etc.) - // Without these, drops on UI elements outside the container fail because - // those elements don't have dragover preventDefault() called. - window.addEventListener('dragover', this.windowDragoverHandler, false); - window.addEventListener('drop', this.windowDropHandler, false); - } - - /** - * Removes event listeners from the container and window. - */ - private removeListeners(): void { - this.container.removeEventListener('dragstart', this.boundHandlers.dragstart); - this.container.removeEventListener('dragover', this.boundHandlers.dragover); - this.container.removeEventListener('drop', this.boundHandlers.drop); - this.container.removeEventListener('dragend', this.boundHandlers.dragend); - this.container.removeEventListener('dragleave', this.boundHandlers.dragleave); - - window.removeEventListener('dragover', this.windowDragoverHandler, false); - window.removeEventListener('drop', this.windowDropHandler, false); - } - - /** - * Handles dragover at window level to allow drops on overlay elements. - * This ensures preventDefault is called even when dragging over selection - * highlights or other UI elements that sit on top of the layout content. - */ - private handleWindowDragOver(event: DragEvent): void { - if (this.hasFieldAnnotationData(event)) { - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - - // Emit dragover event for cursor updates when over overlays outside the container. - // If target is inside container, the container handler will emit the event. - const target = event.target as HTMLElement; - if (!this.container.contains(target)) { - this.config.onDragOver?.({ - event, - clientX: event.clientX, - clientY: event.clientY, - hasFieldAnnotation: true, - }); - } - } - } - - /** - * Handles drop at window level to catch drops on overlay elements. - * If the drop target is outside the container, we process it here. - */ - private handleWindowDrop(event: DragEvent): void { - if (this.hasFieldAnnotationData(event)) { - const target = event.target as HTMLElement; - if (!this.container.contains(target)) { - // Drop landed on an overlay outside the container - process it - this.handleDrop(event); - } - } - } - - /** - * Handles the dragstart event. - * Sets up dataTransfer with field annotation data and drag image. - */ - private handleDragStart(event: DragEvent): void { - const target = event.target as HTMLElement; - - // Check if the target is a draggable field annotation - if (!target?.dataset?.draggable || target.dataset.draggable !== 'true') { - return; - } - - // Extract field annotation data from element - const data = extractFieldAnnotationData(target); - - // Set drag data in multiple formats for compatibility - if (event.dataTransfer) { - const jsonData = JSON.stringify({ - attributes: data.attributes, - sourceField: data, - }); - - // Set in our MIME type - event.dataTransfer.setData(this.mimeType, jsonData); - - // Also set in legacy format for super-editor compatibility - event.dataTransfer.setData(LEGACY_MIME_TYPE, jsonData); - - // Set plain text fallback - event.dataTransfer.setData('text/plain', data.displayLabel ?? 'Field Annotation'); - - // Set the drag image to the annotation element - event.dataTransfer.setDragImage(target, 0, 0); - - // Set the effect to move (can be overridden by consumers) - event.dataTransfer.effectAllowed = 'move'; - } - - // Emit drag start event - this.config.onDragStart?.({ - event, - element: target, - data, - }); - } - - /** - * Handles the dragover event. - * Provides visual feedback and determines if drop is allowed. - */ - private handleDragOver(event: DragEvent): void { - const hasFieldAnnotation = this.hasFieldAnnotationData(event); - - if (hasFieldAnnotation) { - // Prevent default to allow drop - event.preventDefault(); - - // Set drop effect - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - - // Add visual indicator to container - this.container.classList.add('drag-over'); - } - - // Emit drag over event - this.config.onDragOver?.({ - event, - clientX: event.clientX, - clientY: event.clientY, - hasFieldAnnotation, - }); - } - - /** - * Handles the dragleave event. - * Removes visual feedback when drag leaves the container. - */ - private handleDragLeave(event: DragEvent): void { - // Only remove the class if we're leaving the container itself - // (not just moving between child elements) - const relatedTarget = event.relatedTarget as Node | null; - if (!relatedTarget || !this.container.contains(relatedTarget)) { - this.container.classList.remove('drag-over'); - } - } - - /** - * Handles the drop event. - * Maps drop coordinates to ProseMirror position and emits drop event. - */ - private handleDrop(event: DragEvent): void { - // Remove visual feedback - this.container.classList.remove('drag-over'); - - // Check if this drop contains field annotation data - if (!this.hasFieldAnnotationData(event)) { - return; - } - - // Prevent default browser handling - event.preventDefault(); - - // Extract field annotation data from dataTransfer - const data = this.extractDragData(event); - - if (!data) { - return; - } - - // Map drop coordinates to ProseMirror position using DOM mapping - const pmPosition = clickToPositionDom(this.container, event.clientX, event.clientY); - - // Emit drop event - this.config.onDrop?.({ - event, - data, - pmPosition, - clientX: event.clientX, - clientY: event.clientY, - }); - } - - /** - * Handles the dragend event. - * Cleans up drag state. - */ - private handleDragEnd(event: DragEvent): void { - // Remove visual feedback - this.container.classList.remove('drag-over'); - - // Emit drag end event - this.config.onDragEnd?.(event); - } - - /** - * Checks if a drag event contains field annotation data. - */ - private hasFieldAnnotationData(event: DragEvent): boolean { - if (!event.dataTransfer) { - return false; - } - - const types = event.dataTransfer.types; - return types.includes(this.mimeType) || types.includes(LEGACY_MIME_TYPE); - } - - /** - * Extracts field annotation data from a drag event's dataTransfer. - */ - private extractDragData(event: DragEvent): FieldAnnotationDragData | null { - if (!event.dataTransfer) { - return null; - } - - // Try our MIME type first, then legacy - let jsonData = event.dataTransfer.getData(this.mimeType); - if (!jsonData) { - jsonData = event.dataTransfer.getData(LEGACY_MIME_TYPE); - } - - if (!jsonData) { - return null; - } - - try { - const parsed = JSON.parse(jsonData); - // Handle both { attributes, sourceField } format and direct data format - return parsed.sourceField ?? parsed.attributes ?? parsed; - } catch { - return null; - } - } - - /** - * Updates the configuration options. - * - * @param config - New configuration options to merge - */ - updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - if (config.mimeType) { - this.mimeType = config.mimeType; - } - } - - /** - * Destroys the drag handler and removes all event listeners. - * Call this when the layout engine is unmounted or the container is removed. - */ - destroy(): void { - this.removeListeners(); - this.container.classList.remove('drag-over'); - } -} - -/** - * Creates a simple drag handler that just emits events without managing state. - * Useful for integrating with existing event systems. - * - * @param container - The DOM container element - * @param config - Configuration options and callbacks - * @returns A cleanup function to remove event listeners - * - * @example - * ```typescript - * const cleanup = createDragHandler(container, { - * onDrop: (e) => editor.emit('fieldAnnotationDropped', e), - * }); - * - * // Later, when cleaning up: - * cleanup(); - * ``` - */ -export function createDragHandler(container: HTMLElement, config: DragHandlerConfig = {}): () => void { - const handler = new DragHandler(container, config); - return () => handler.destroy(); -} diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index ab65a9877..8e969926c 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -168,20 +168,6 @@ export { getRunBooleanProp, } from './paragraph-hash-utils'; -// Drag Handler -export { DragHandler, createDragHandler } from './drag-handler'; -export type { - FieldAnnotationDragData, - DragStartEvent, - DropEvent, - DragOverEvent, - DragStartCallback, - DropCallback, - DragOverCallback, - DragEndCallback, - DragHandlerConfig, -} from './drag-handler'; - export type Point = { x: number; y: number }; export type PageHit = { pageIndex: number; page: Layout['pages'][number] }; export type FragmentHit = { diff --git a/packages/layout-engine/layout-bridge/test/drag-handler.test.ts b/packages/layout-engine/layout-bridge/test/drag-handler.test.ts deleted file mode 100644 index 749875b06..000000000 --- a/packages/layout-engine/layout-bridge/test/drag-handler.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -/** - * Tests for DragHandler - * - * @vitest-environment jsdom - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { DragHandler, createDragHandler, type DragHandlerConfig } from '../src/drag-handler'; - -describe('DragHandler', () => { - let container: HTMLDivElement; - let handler: DragHandler; - let config: DragHandlerConfig; - - beforeEach(() => { - // Create a mock container element - container = document.createElement('div'); - container.classList.add('superdoc-layout'); - document.body.appendChild(container); - - // Default config with mock callbacks - config = { - onDragStart: vi.fn(), - onDrop: vi.fn(), - onDragOver: vi.fn(), - onDragEnd: vi.fn(), - }; - }); - - afterEach(() => { - handler?.destroy(); - document.body.removeChild(container); - }); - - /** - * Creates a mock draggable field annotation element. - */ - function createDraggableAnnotation(data: Record = {}): HTMLSpanElement { - const annotation = document.createElement('span'); - annotation.classList.add('annotation'); - annotation.draggable = true; - annotation.dataset.draggable = 'true'; - annotation.dataset.fieldId = data.fieldId ?? 'field-123'; - annotation.dataset.fieldType = data.fieldType ?? 'TEXTINPUT'; - annotation.dataset.variant = data.variant ?? 'text'; - annotation.dataset.displayLabel = data.displayLabel ?? 'Test Field'; - annotation.dataset.pmStart = data.pmStart ?? '10'; - annotation.dataset.pmEnd = data.pmEnd ?? '11'; - return annotation; - } - - /** - * Creates a mock DragEvent. - */ - function createDragEvent( - type: string, - options: { - target?: HTMLElement; - dataTransfer?: Partial; - clientX?: number; - clientY?: number; - } = {}, - ): DragEvent { - const dataTransferData: Map = new Map(); - - const mockDataTransfer = { - data: dataTransferData, - types: [] as string[], - effectAllowed: 'uninitialized' as DataTransfer['effectAllowed'], - dropEffect: 'none' as DataTransfer['dropEffect'], - setData(type: string, data: string) { - this.data.set(type, data); - if (!this.types.includes(type)) { - this.types.push(type); - } - }, - getData(type: string) { - return this.data.get(type) ?? ''; - }, - setDragImage: vi.fn(), - clearData: vi.fn(), - files: [] as unknown as FileList, - items: [] as unknown as DataTransferItemList, - ...options.dataTransfer, - }; - - const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent; - - // Add dataTransfer and other properties - Object.defineProperties(event, { - dataTransfer: { value: mockDataTransfer, writable: true }, - clientX: { value: options.clientX ?? 100, writable: true }, - clientY: { value: options.clientY ?? 100, writable: true }, - target: { value: options.target ?? container, writable: true }, - }); - - return event; - } - - describe('constructor', () => { - it('should create a handler instance', () => { - handler = new DragHandler(container, config); - expect(handler).toBeInstanceOf(DragHandler); - }); - - it('should attach event listeners to the container', () => { - const addEventListenerSpy = vi.spyOn(container, 'addEventListener'); - handler = new DragHandler(container, config); - - expect(addEventListenerSpy).toHaveBeenCalledWith('dragstart', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('dragend', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function)); - }); - }); - - describe('dragstart handling', () => { - beforeEach(() => { - handler = new DragHandler(container, config); - }); - - it('should handle dragstart on a field annotation element', () => { - const annotation = createDraggableAnnotation(); - container.appendChild(annotation); - - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - expect(config.onDragStart).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.any(Object), - element: annotation, - data: expect.objectContaining({ - fieldId: 'field-123', - fieldType: 'TEXTINPUT', - variant: 'text', - displayLabel: 'Test Field', - pmStart: 10, - pmEnd: 11, - }), - }), - ); - }); - - it('should set dataTransfer data in multiple formats', () => { - const annotation = createDraggableAnnotation(); - container.appendChild(annotation); - - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - // Check that data was set in multiple formats - expect(event.dataTransfer?.types).toContain('application/x-field-annotation'); - expect(event.dataTransfer?.types).toContain('fieldAnnotation'); - expect(event.dataTransfer?.types).toContain('text/plain'); - }); - - it('should set drag image to the annotation element', () => { - const annotation = createDraggableAnnotation(); - container.appendChild(annotation); - - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - expect(event.dataTransfer?.setDragImage).toHaveBeenCalledWith(annotation, 0, 0); - }); - - it('should not handle dragstart on non-draggable elements', () => { - const regularElement = document.createElement('span'); - container.appendChild(regularElement); - - const event = createDragEvent('dragstart', { target: regularElement }); - regularElement.dispatchEvent(event); - - expect(config.onDragStart).not.toHaveBeenCalled(); - }); - - it('should set effectAllowed to move', () => { - const annotation = createDraggableAnnotation(); - container.appendChild(annotation); - - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - expect(event.dataTransfer?.effectAllowed).toBe('move'); - }); - }); - - describe('dragover handling', () => { - beforeEach(() => { - handler = new DragHandler(container, config); - }); - - it('should handle dragover with field annotation data', () => { - // Simulate a drag that started with field annotation data - const event = createDragEvent('dragover'); - event.dataTransfer?.setData('application/x-field-annotation', '{}'); - - container.dispatchEvent(event); - - expect(config.onDragOver).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.any(Object), - clientX: 100, - clientY: 100, - hasFieldAnnotation: true, - }), - ); - }); - - it('should add drag-over class to container when dragging field annotation', () => { - const event = createDragEvent('dragover'); - event.dataTransfer?.setData('application/x-field-annotation', '{}'); - - container.dispatchEvent(event); - - expect(container.classList.contains('drag-over')).toBe(true); - }); - - it('should not add drag-over class for non-field-annotation drags', () => { - const event = createDragEvent('dragover'); - // No field annotation data in dataTransfer - - container.dispatchEvent(event); - - expect(container.classList.contains('drag-over')).toBe(false); - }); - - it('should set dropEffect to move for field annotation drags', () => { - const event = createDragEvent('dragover'); - event.dataTransfer?.setData('application/x-field-annotation', '{}'); - - container.dispatchEvent(event); - - expect(event.dataTransfer?.dropEffect).toBe('move'); - }); - }); - - describe('drop handling', () => { - beforeEach(() => { - handler = new DragHandler(container, config); - }); - - it('should handle drop with field annotation data', () => { - const dragData = JSON.stringify({ - attributes: { fieldId: 'field-456', variant: 'image' }, - sourceField: { fieldId: 'field-456', variant: 'image', displayLabel: 'Image Field' }, - }); - - const event = createDragEvent('drop', { clientX: 150, clientY: 200 }); - event.dataTransfer?.setData('application/x-field-annotation', dragData); - - container.dispatchEvent(event); - - expect(config.onDrop).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.any(Object), - data: expect.objectContaining({ - fieldId: 'field-456', - variant: 'image', - displayLabel: 'Image Field', - }), - clientX: 150, - clientY: 200, - }), - ); - }); - - it('should remove drag-over class on drop', () => { - container.classList.add('drag-over'); - - const event = createDragEvent('drop'); - event.dataTransfer?.setData('application/x-field-annotation', '{}'); - - container.dispatchEvent(event); - - expect(container.classList.contains('drag-over')).toBe(false); - }); - - it('should handle legacy fieldAnnotation MIME type', () => { - const dragData = JSON.stringify({ - sourceField: { fieldId: 'legacy-field', variant: 'text' }, - }); - - const event = createDragEvent('drop'); - event.dataTransfer?.setData('fieldAnnotation', dragData); - - container.dispatchEvent(event); - - expect(config.onDrop).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - fieldId: 'legacy-field', - variant: 'text', - }), - }), - ); - }); - - it('should not handle drop without field annotation data', () => { - const event = createDragEvent('drop'); - // No field annotation data - - container.dispatchEvent(event); - - expect(config.onDrop).not.toHaveBeenCalled(); - }); - }); - - describe('dragend handling', () => { - beforeEach(() => { - handler = new DragHandler(container, config); - }); - - it('should handle dragend event', () => { - const event = createDragEvent('dragend'); - container.dispatchEvent(event); - - expect(config.onDragEnd).toHaveBeenCalledWith(expect.any(Object)); - }); - - it('should remove drag-over class on dragend', () => { - container.classList.add('drag-over'); - - const event = createDragEvent('dragend'); - container.dispatchEvent(event); - - expect(container.classList.contains('drag-over')).toBe(false); - }); - }); - - describe('dragleave handling', () => { - beforeEach(() => { - handler = new DragHandler(container, config); - }); - - it('should remove drag-over class when leaving container', () => { - container.classList.add('drag-over'); - - const event = new Event('dragleave', { bubbles: true }) as DragEvent; - Object.defineProperty(event, 'relatedTarget', { value: document.body }); - - container.dispatchEvent(event); - - expect(container.classList.contains('drag-over')).toBe(false); - }); - - it('should not remove drag-over class when moving to child element', () => { - container.classList.add('drag-over'); - - const child = document.createElement('span'); - container.appendChild(child); - - const event = new Event('dragleave', { bubbles: true }) as DragEvent; - Object.defineProperty(event, 'relatedTarget', { value: child }); - - container.dispatchEvent(event); - - expect(container.classList.contains('drag-over')).toBe(true); - }); - }); - - describe('updateConfig', () => { - it('should update configuration options', () => { - handler = new DragHandler(container, config); - - const newOnDrop = vi.fn(); - handler.updateConfig({ onDrop: newOnDrop }); - - const dragData = JSON.stringify({ sourceField: {} }); - const event = createDragEvent('drop'); - event.dataTransfer?.setData('application/x-field-annotation', dragData); - - container.dispatchEvent(event); - - expect(newOnDrop).toHaveBeenCalled(); - expect(config.onDrop).not.toHaveBeenCalled(); - }); - - it('should update custom MIME type', () => { - handler = new DragHandler(container, config); - handler.updateConfig({ mimeType: 'custom/mime-type' }); - - const annotation = createDraggableAnnotation(); - container.appendChild(annotation); - - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - expect(event.dataTransfer?.types).toContain('custom/mime-type'); - }); - }); - - describe('destroy', () => { - it('should remove event listeners', () => { - const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener'); - handler = new DragHandler(container, config); - - handler.destroy(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith('dragstart', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('dragend', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function)); - }); - - it('should remove drag-over class', () => { - handler = new DragHandler(container, config); - container.classList.add('drag-over'); - - handler.destroy(); - - expect(container.classList.contains('drag-over')).toBe(false); - }); - }); - - describe('createDragHandler helper', () => { - it('should create a handler and return cleanup function', () => { - const cleanup = createDragHandler(container, config); - - expect(typeof cleanup).toBe('function'); - - // Verify handler is active - const annotation = createDraggableAnnotation(); - container.appendChild(annotation); - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - expect(config.onDragStart).toHaveBeenCalled(); - - // Cleanup - cleanup(); - }); - - it('should properly cleanup when called', () => { - const cleanup = createDragHandler(container, config); - cleanup(); - - // Verify handler is no longer active - const annotation = createDraggableAnnotation(); - container.appendChild(annotation); - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - // onDragStart should not be called after cleanup - expect(config.onDragStart).toHaveBeenCalledTimes(0); - }); - }); - - describe('field annotation data extraction', () => { - beforeEach(() => { - handler = new DragHandler(container, config); - }); - - it('should extract all data attributes from element', () => { - const annotation = createDraggableAnnotation({ - fieldId: 'custom-id', - fieldType: 'CHECKBOX', - variant: 'checkbox', - displayLabel: 'Custom Label', - pmStart: '50', - pmEnd: '51', - }); - container.appendChild(annotation); - - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - expect(config.onDragStart).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - fieldId: 'custom-id', - fieldType: 'CHECKBOX', - variant: 'checkbox', - displayLabel: 'Custom Label', - pmStart: 50, - pmEnd: 51, - }), - }), - ); - }); - - it('should handle elements with partial data', () => { - const annotation = document.createElement('span'); - annotation.dataset.draggable = 'true'; - annotation.dataset.fieldId = 'partial-field'; - // Missing other attributes - container.appendChild(annotation); - - const event = createDragEvent('dragstart', { target: annotation }); - annotation.dispatchEvent(event); - - expect(config.onDragStart).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - fieldId: 'partial-field', - fieldType: undefined, - variant: undefined, - }), - }), - ); - }); - }); -}); diff --git a/packages/layout-engine/painters/dom/src/link-click.test.ts b/packages/layout-engine/painters/dom/src/link-click.test.ts index f8ee7c2ef..b192ee025 100644 --- a/packages/layout-engine/painters/dom/src/link-click.test.ts +++ b/packages/layout-engine/painters/dom/src/link-click.test.ts @@ -1,12 +1,14 @@ -import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { createDomPainter } from './index.js'; import type { FlowBlock, Measure, Layout } from '@superdoc/contracts'; /** - * Tests for link click event handling in DomPainter. - * Verifies that clicking links prevents navigation and dispatches custom events. + * Tests for link rendering in DomPainter. + * + * Note: Click event handling has been moved to EditorInputManager via event delegation. + * These tests verify that links are rendered with correct attributes for delegation to work. */ -describe('DomPainter - Link Click Handling', () => { +describe('DomPainter - Link Rendering', () => { let container: HTMLDivElement; beforeEach(() => { @@ -20,7 +22,7 @@ describe('DomPainter - Link Click Handling', () => { } }); - it('should render link with click event handler', () => { + it('should render link with correct attributes', () => { const linkBlock: FlowBlock = { kind: 'paragraph', id: 'link-block', @@ -81,96 +83,17 @@ describe('DomPainter - Link Click Handling', () => { const painter = createDomPainter({ blocks: [linkBlock], measures: [measure] }); painter.paint(layout, container); - // Find the rendered link element const linkElement = container.querySelector('a.superdoc-link') as HTMLAnchorElement; expect(linkElement).toBeTruthy(); expect(linkElement.href).toBe('https://example.com/'); expect(linkElement.target).toBe('_blank'); expect(linkElement.textContent).toBe('Click here'); + // Verify accessibility attributes for event delegation + expect(linkElement.getAttribute('role')).toBe('link'); + expect(linkElement.getAttribute('tabindex')).toBe('0'); }); - it('should prevent default navigation on link click', () => { - const linkBlock: FlowBlock = { - kind: 'paragraph', - id: 'link-block', - runs: [ - { - text: 'Link text', - fontFamily: 'Arial', - fontSize: 16, - pmStart: 0, - pmEnd: 9, - link: { - href: 'https://test.com', - }, - }, - ], - }; - - const measure: Measure = { - kind: 'paragraph', - lines: [ - { - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 9, - width: 70, - ascent: 12, - descent: 4, - lineHeight: 20, - }, - ], - totalHeight: 20, - }; - - const layout: Layout = { - pageSize: { w: 400, h: 500 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'para', - blockId: 'link-block', - fromLine: 0, - toLine: 1, - x: 24, - y: 24, - width: 260, - pmStart: 0, - pmEnd: 9, - }, - ], - }, - ], - }; - - const painter = createDomPainter({ blocks: [linkBlock], measures: [measure] }); - painter.paint(layout, container); - - const linkElement = container.querySelector('a.superdoc-link') as HTMLAnchorElement; - expect(linkElement).toBeTruthy(); - - // Create a mock click event - const clickEvent = new MouseEvent('click', { - bubbles: true, - cancelable: true, - clientX: 100, - clientY: 100, - }); - - const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault'); - const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation'); - - linkElement.dispatchEvent(clickEvent); - - // Verify preventDefault and stopPropagation were called - expect(preventDefaultSpy).toHaveBeenCalled(); - expect(stopPropagationSpy).toHaveBeenCalled(); - }); - - it('should dispatch custom superdoc-link-click event with correct metadata', () => { + it('should render link with all optional attributes', () => { const linkBlock: FlowBlock = { kind: 'paragraph', id: 'link-block', @@ -235,46 +158,13 @@ describe('DomPainter - Link Click Handling', () => { const linkElement = container.querySelector('a.superdoc-link') as HTMLAnchorElement; expect(linkElement).toBeTruthy(); - - // Setup event listener to capture the custom event - let capturedEvent: CustomEvent | null = null; - const customEventListener = (event: Event) => { - capturedEvent = event as CustomEvent; - }; - - linkElement.addEventListener('superdoc-link-click', customEventListener); - - // Trigger click - const clickEvent = new MouseEvent('click', { - bubbles: true, - cancelable: true, - clientX: 200, - clientY: 300, - }); - - linkElement.dispatchEvent(clickEvent); - - // Verify custom event was dispatched with correct detail - expect(capturedEvent).toBeTruthy(); - expect(capturedEvent?.type).toBe('superdoc-link-click'); - expect(capturedEvent?.bubbles).toBe(true); - expect(capturedEvent?.composed).toBe(true); - // Note: tooltip might be null if encoding/validation removes it - const detailToCheck = { - href: 'https://example.org', - target: '_blank', - rel: 'noopener noreferrer', - clientX: 200, - clientY: 300, - }; - expect(capturedEvent?.detail).toMatchObject(detailToCheck); - expect(capturedEvent?.detail.element).toBe(linkElement); - - // Cleanup - linkElement.removeEventListener('superdoc-link-click', customEventListener); + expect(linkElement.href).toBe('https://example.org/'); + expect(linkElement.target).toBe('_blank'); + expect(linkElement.rel).toBe('noopener noreferrer'); + expect(linkElement.title).toBe('Example tooltip'); }); - it('should handle link without optional attributes', () => { + it('should render link without optional attributes', () => { const linkBlock: FlowBlock = { kind: 'paragraph', id: 'link-block', @@ -336,26 +226,13 @@ describe('DomPainter - Link Click Handling', () => { const linkElement = container.querySelector('a.superdoc-link') as HTMLAnchorElement; expect(linkElement).toBeTruthy(); - - // Setup event listener - let capturedEvent: CustomEvent | null = null; - linkElement.addEventListener('superdoc-link-click', (event: Event) => { - capturedEvent = event as CustomEvent; - }); - - // Trigger click - linkElement.click(); - - // Verify event was dispatched with minimal metadata - // Note: System auto-adds target='_blank' and rel='noopener noreferrer' for external links - expect(capturedEvent).toBeTruthy(); - expect(capturedEvent?.detail.href).toBe('https://simple.com'); - expect(capturedEvent?.detail.target).toBe('_blank'); // Auto-added for external links - expect(capturedEvent?.detail.rel).toBe('noopener noreferrer'); // Auto-added for _blank target - expect(capturedEvent?.detail.tooltip).toBeNull(); // null if not specified + expect(linkElement.href).toBe('https://simple.com/'); + // External links automatically get target and rel for security + expect(linkElement.target).toBe('_blank'); + expect(linkElement.rel).toBe('noopener noreferrer'); }); - it('should handle multiple links in the same paragraph', () => { + it('should render multiple links in the same paragraph', () => { const linkBlock: FlowBlock = { kind: 'paragraph', id: 'multi-link-block', @@ -440,27 +317,9 @@ describe('DomPainter - Link Click Handling', () => { expect(firstLink.href).toBe('https://first.com/'); expect(secondLink.href).toBe('https://second.com/'); - - // Verify both links have click handlers - let firstEventCaptured = false; - let secondEventCaptured = false; - - firstLink.addEventListener('superdoc-link-click', () => { - firstEventCaptured = true; - }); - - secondLink.addEventListener('superdoc-link-click', () => { - secondEventCaptured = true; - }); - - firstLink.click(); - secondLink.click(); - - expect(firstEventCaptured).toBe(true); - expect(secondEventCaptured).toBe(true); }); - it('should handle non-link text runs without adding click handlers', () => { + it('should render non-link text runs as spans', () => { const mixedBlock: FlowBlock = { kind: 'paragraph', id: 'mixed-block', @@ -527,12 +386,9 @@ describe('DomPainter - Link Click Handling', () => { const painter = createDomPainter({ blocks: [mixedBlock], measures: [measure] }); painter.paint(layout, container); - // Should have one link and one span + // Should have one link and spans for regular text const linkElements = container.querySelectorAll('a.superdoc-link'); - const spanElements = container.querySelectorAll('span'); - expect(linkElements.length).toBe(1); - expect(spanElements.length).toBeGreaterThanOrEqual(1); const linkElement = linkElements[0] as HTMLAnchorElement; expect(linkElement.textContent).toBe('with link'); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 54a647d85..5636bb845 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3815,28 +3815,8 @@ export class DomPainter { // Ensure link is keyboard accessible (should be default for , but verify) elem.setAttribute('tabindex', '0'); - // Add click handler to prevent navigation and dispatch custom event for link editing - elem.addEventListener('click', (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - // Dispatch custom event with link metadata for the editor to handle - const linkClickEvent = new CustomEvent('superdoc-link-click', { - bubbles: true, - composed: true, - detail: { - href: linkData.href, - target: linkData.target, - rel: linkData.rel, - tooltip: linkData.tooltip, - element: elem, - clientX: event.clientX, - clientY: event.clientY, - }, - }); - - elem.dispatchEvent(linkClickEvent); - }); + // Note: Click handling is done via event delegation in EditorInputManager, + // not per-element handlers. This avoids duplicate event dispatching. } /** diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 022983bc4..ada7eded7 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -23,6 +23,7 @@ import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/P import { safeCleanup } from './utils/SafeCleanup.js'; import { createHiddenHost } from './dom/HiddenHost.js'; import { RemoteCursorManager, type RenderDependencies } from './remote-cursors/RemoteCursorManager.js'; +import { EditorInputManager } from './pointer-events/EditorInputManager.js'; import { SelectionSyncCoordinator } from './selection/SelectionSyncCoordinator.js'; import { PresentationInputBridge } from './input/PresentationInputBridge.js'; import { calculateExtendedSelection } from './selection/SelectionHelpers.js'; @@ -56,11 +57,7 @@ import { hitTestTable as hitTestTableFromHelper, shouldUseCellSelection as shouldUseCellSelectionFromHelper, } from './tables/TableSelectionUtilities.js'; -import { - createExternalFieldAnnotationDragOverHandler, - createExternalFieldAnnotationDropHandler, - setupInternalFieldAnnotationDragHandlers, -} from './input/FieldAnnotationDragDrop.js'; +import { DragDropManager } from './input/DragDropManager.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; import { decodeRPrFromMarks } from '../super-converter/styles.js'; import { halfPointToPoints } from '../super-converter/helpers.js'; @@ -262,7 +259,7 @@ export class PresentationEditor extends EventEmitter { #layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() }; #domPainter: ReturnType | null = null; #pageGeometryHelper: PageGeometryHelper | null = null; - #dragHandlerCleanup: (() => void) | null = null; + #dragDropManager: DragDropManager | null = null; #layoutError: LayoutError | null = null; #layoutErrorState: 'healthy' | 'degraded' | 'failed' = 'healthy'; #errorBanner: HTMLElement | null = null; @@ -279,9 +276,6 @@ export class PresentationEditor extends EventEmitter { #htmlAnnotationMeasureAttempts = 0; #domPositionIndex = new DomPositionIndex(); #domIndexObserverManager: DomPositionIndexObserverManager | null = null; - #debugLastPointer: SelectionDebugHudState['lastPointer'] = null; - #debugLastHit: SelectionDebugHudState['lastHit'] = null; - #pendingMarginClick: PendingMarginClick | null = null; #rafHandle: number | null = null; #editorListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; #sectionMetadata: SectionMetadata[] = []; @@ -298,32 +292,11 @@ export class PresentationEditor extends EventEmitter { #ariaLiveRegion: HTMLElement | null = null; #a11ySelectionAnnounceTimeout: number | null = null; #a11yLastAnnouncedSelectionKey: string | null = null; - #clickCount = 0; - #lastClickTime = 0; - #lastClickPosition: { x: number; y: number } = { x: 0, y: 0 }; - #lastSelectedImageBlockId: string | null = null; #lastSelectedFieldAnnotation: { element: HTMLElement; pmStart: number; } | null = null; - // Drag selection state - #dragAnchor: number | null = null; - #dragAnchorPageIndex: number | null = null; - #isDragging = false; - #dragExtensionMode: 'char' | 'word' | 'para' = 'char'; - #dragLastPointer: SelectionDebugHudState['lastPointer'] = null; - #dragLastRawHit: PositionHit | null = null; - #dragUsedPageNotMountedFallback = false; - #suppressFocusInFromDraggable = false; - - // Cell selection drag state - // Tracks cell-specific context when drag starts in a table for multi-cell selection - #cellAnchor: CellAnchorState | null = null; - - /** Cell drag mode state machine: 'none' = not in table, 'pending' = in table but haven't crossed cell boundary, 'active' = crossed cell boundary */ - #cellDragMode: 'none' | 'pending' | 'active' = 'none'; - // Remote cursor/presence state management /** Manager for remote cursor rendering and awareness subscriptions */ #remoteCursorManager: RemoteCursorManager | null = null; @@ -332,6 +305,10 @@ export class PresentationEditor extends EventEmitter { /** DOM element for rendering local selection/caret (dual-layer overlay architecture) */ #localSelectionLayer: HTMLElement | null = null; + // Editor input management + /** Manager for pointer events, focus, drag selection, and click handling */ + #editorInputManager: EditorInputManager | null = null; + constructor(options: PresentationEditorOptions) { super(); @@ -586,6 +563,7 @@ export class PresentationEditor extends EventEmitter { this.#setupHeaderFooterSession(); this.#applyZoom(); this.#setupEditorListeners(); + this.#initializeEditorInputManager(); this.#setupPointerHandlers(); this.#setupDragHandlers(); this.#setupInputBridge(); @@ -1666,8 +1644,8 @@ export class PresentationEditor extends EventEmitter { docEpoch: this.#epochMapper.getCurrentEpoch(), layoutEpoch: this.#layoutEpoch, selection, - lastPointer: this.#debugLastPointer, - lastHit: this.#debugLastHit, + lastPointer: this.#editorInputManager?.debugLastPointer ?? null, + lastHit: this.#editorInputManager?.debugLastHit ?? null, }); } catch { // Debug HUD should never break editor interaction paths @@ -2153,15 +2131,14 @@ export class PresentationEditor extends EventEmitter { this.#domIndexObserverManager?.destroy(); this.#domIndexObserverManager = null; - this.#viewportHost?.removeEventListener('pointerdown', this.#handlePointerDown); - this.#viewportHost?.removeEventListener('dblclick', this.#handleDoubleClick); - this.#viewportHost?.removeEventListener('pointermove', this.#handlePointerMove); - this.#viewportHost?.removeEventListener('pointerup', this.#handlePointerUp); - this.#viewportHost?.removeEventListener('pointerleave', this.#handlePointerLeave); - this.#viewportHost?.removeEventListener('dragover', this.#handleDragOver); - this.#viewportHost?.removeEventListener('drop', this.#handleDrop); - this.#visibleHost?.removeEventListener('keydown', this.#handleKeyDown); - this.#visibleHost?.removeEventListener('focusin', this.#handleVisibleHostFocusIn); + // Clean up editor input manager (handles event listeners and drag/cell state) + if (this.#editorInputManager) { + safeCleanup(() => { + this.#editorInputManager?.destroy(); + this.#editorInputManager = null; + }, 'Editor input manager'); + } + this.#inputBridge?.notifyTargetChanged(); this.#inputBridge?.destroy(); this.#inputBridge = null; @@ -2171,9 +2148,6 @@ export class PresentationEditor extends EventEmitter { this.#a11ySelectionAnnounceTimeout = null; } - // Clean up cell selection drag state to prevent memory leaks - this.#clearCellAnchor(); - // Unregister from static registry if (this.#options?.documentId) { PresentationEditor.#instances.delete(this.#options.documentId); @@ -2187,8 +2161,8 @@ export class PresentationEditor extends EventEmitter { this.#domPainter = null; this.#pageGeometryHelper = null; - this.#dragHandlerCleanup?.(); - this.#dragHandlerCleanup = null; + this.#dragDropManager?.destroy(); + this.#dragDropManager = null; this.#selectionOverlay?.remove(); this.#painterHost?.remove(); this.#hiddenHost?.remove(); @@ -2246,7 +2220,7 @@ export class PresentationEditor extends EventEmitter { this.#updateLocalAwarenessCursor(); // Clear cell anchor on document changes to prevent stale references // (table structure may have changed, cell positions may be invalid) - this.#clearCellAnchor(); + this.#editorInputManager?.clearCellAnchor(); } }; const handleSelection = () => { @@ -2376,34 +2350,85 @@ export class PresentationEditor extends EventEmitter { this.#remoteCursorManager?.render(this.#getRemoteCursorRenderDeps()); } + /** + * Initialize the EditorInputManager with dependencies and callbacks. + * @private + */ + #initializeEditorInputManager(): void { + this.#editorInputManager = new EditorInputManager(); + + // Set dependencies - getters that provide access to PresentationEditor state + this.#editorInputManager.setDependencies({ + getActiveEditor: () => this.getActiveEditor(), + getEditor: () => this.#editor, + getLayoutState: () => this.#layoutState, + getEpochMapper: () => this.#epochMapper, + getViewportHost: () => this.#viewportHost, + getVisibleHost: () => this.#visibleHost, + getHeaderFooterSession: () => this.#headerFooterSession, + getPageGeometryHelper: () => this.#pageGeometryHelper, + getZoom: () => this.#layoutOptions.zoom ?? 1, + isViewLocked: () => this.#isViewLocked(), + getDocumentMode: () => this.#documentMode, + getPageElement: (pageIndex: number) => this.#getPageElement(pageIndex), + isSelectionAwareVirtualizationEnabled: () => this.#isSelectionAwareVirtualizationEnabled(), + }); + + // Set callbacks - functions that the manager calls to interact with PresentationEditor + this.#editorInputManager.setCallbacks({ + scheduleSelectionUpdate: () => this.#scheduleSelectionUpdate(), + scheduleRerender: () => this.#scheduleRerender(), + setPendingDocChange: () => { + this.#pendingDocChange = true; + }, + updateSelectionVirtualizationPins: (options) => this.#updateSelectionVirtualizationPins(options), + scheduleA11ySelectionAnnouncement: (options) => this.#scheduleA11ySelectionAnnouncement(options), + goToAnchor: (href: string) => this.goToAnchor(href), + emit: (event: string, payload: unknown) => this.emit(event, payload), + normalizeClientPoint: (clientX: number, clientY: number) => this.#normalizeClientPoint(clientX, clientY), + hitTestHeaderFooterRegion: (x: number, y: number) => this.#hitTestHeaderFooterRegion(x, y), + exitHeaderFooterMode: () => this.#exitHeaderFooterMode(), + activateHeaderFooterRegion: (region) => this.#activateHeaderFooterRegion(region), + createDefaultHeaderFooter: (region) => this.#createDefaultHeaderFooter(region), + emitHeaderFooterEditBlocked: (reason: string) => this.#emitHeaderFooterEditBlocked(reason), + findRegionForPage: (kind, pageIndex) => this.#findRegionForPage(kind, pageIndex), + getCurrentPageIndex: () => this.#getCurrentPageIndex(), + resolveDescriptorForRegion: (region) => this.#resolveDescriptorForRegion(region), + updateSelectionDebugHud: () => this.#updateSelectionDebugHud(), + clearHoverRegion: () => this.#clearHoverRegion(), + renderHoverRegion: (region) => this.#renderHoverRegion(region), + focusEditorAfterImageSelection: () => this.#focusEditorAfterImageSelection(), + resolveFieldAnnotationSelectionFromElement: (el) => this.#resolveFieldAnnotationSelectionFromElement(el), + computePendingMarginClick: (pointerId, x, y) => this.#computePendingMarginClick(pointerId, x, y), + selectWordAt: (pos: number) => this.#selectWordAt(pos), + selectParagraphAt: (pos: number) => this.#selectParagraphAt(pos), + finalizeDragSelectionWithDom: (pointer, dragAnchor, dragMode) => + this.#finalizeDragSelectionWithDom(pointer, dragAnchor, dragMode), + hitTestTable: (x: number, y: number) => this.#hitTestTable(x, y), + }); + } + #setupPointerHandlers() { - this.#viewportHost.addEventListener('pointerdown', this.#handlePointerDown); - this.#viewportHost.addEventListener('dblclick', this.#handleDoubleClick); - this.#viewportHost.addEventListener('pointermove', this.#handlePointerMove); - this.#viewportHost.addEventListener('pointerup', this.#handlePointerUp); - this.#viewportHost.addEventListener('pointerleave', this.#handlePointerLeave); - this.#viewportHost.addEventListener('dragover', this.#handleDragOver); - this.#viewportHost.addEventListener('drop', this.#handleDrop); - this.#visibleHost.addEventListener('keydown', this.#handleKeyDown); - this.#visibleHost.addEventListener('focusin', this.#handleVisibleHostFocusIn); + // Delegate to EditorInputManager for pointer events + this.#editorInputManager?.bind(); } /** - * Sets up drag and drop handlers for field annotations in the layout engine view. - * Uses the DragHandler from layout-bridge to handle drag events and map drop - * coordinates to ProseMirror positions. + * Sets up drag and drop handlers for field annotations. */ #setupDragHandlers() { - // Clean up any existing handler - this.#dragHandlerCleanup?.(); - this.#dragHandlerCleanup = null; + // Clean up any existing manager + this.#dragDropManager?.destroy(); - this.#dragHandlerCleanup = setupInternalFieldAnnotationDragHandlers({ - painterHost: this.#painterHost, + this.#dragDropManager = new DragDropManager(); + this.#dragDropManager.setDependencies({ getActiveEditor: () => this.getActiveEditor(), hitTest: (clientX, clientY) => this.hitTest(clientX, clientY), scheduleSelectionUpdate: () => this.#scheduleSelectionUpdate(), + getViewportHost: () => this.#viewportHost, + getPainterHost: () => this.#painterHost, }); + this.#dragDropManager.bind(); } /** @@ -2545,692 +2570,6 @@ export class PresentationEditor extends EventEmitter { this.#headerFooterSession.initialize(); } - #handlePointerDown = (event: PointerEvent) => { - // Return early for non-left clicks (right-click, middle-click) - if (event.button !== 0) { - return; - } - - // On Mac, Ctrl+Click triggers the context menu but reports button=0. - // Treat it like a right-click: preserve selection and let the contextmenu handler take over. - // This prevents the selection from being destroyed when user Ctrl+clicks on selected text. - if (event.ctrlKey && navigator.platform.includes('Mac')) { - return; - } - - this.#pendingMarginClick = null; - - // Check if clicking on a draggable field annotation - if so, don't preventDefault - // to allow native HTML5 drag-and-drop to work (mousedown must fire for dragstart) - const target = event.target as HTMLElement; - if (target?.closest?.('.superdoc-ruler-handle') != null) { - return; - } - - // Handle clicks on links in the layout engine - const linkEl = target?.closest?.('a.superdoc-link') as HTMLAnchorElement | null; - if (linkEl) { - const href = linkEl.getAttribute('href') ?? ''; - const isAnchorLink = href.startsWith('#') && href.length > 1; - const isTocLink = linkEl.closest('.superdoc-toc-entry') !== null; - - if (isAnchorLink && isTocLink) { - // TOC entry anchor links: navigate to the anchor - event.preventDefault(); - event.stopPropagation(); - this.goToAnchor(href); - return; - } - - // Non-TOC links: dispatch custom event to show the link popover - // We dispatch from pointerdown because the DOM may be re-rendered before click fires, - // which would cause the click event to land on the wrong element - event.preventDefault(); - event.stopPropagation(); - - const linkClickEvent = new CustomEvent('superdoc-link-click', { - bubbles: true, - composed: true, - detail: { - href: href, - target: linkEl.getAttribute('target'), - rel: linkEl.getAttribute('rel'), - tooltip: linkEl.getAttribute('title'), - element: linkEl, - clientX: event.clientX, - clientY: event.clientY, - }, - }); - linkEl.dispatchEvent(linkClickEvent); - return; - } - - const annotationEl = target?.closest?.('.annotation[data-pm-start]') as HTMLElement | null; - const isDraggableAnnotation = target?.closest?.('[data-draggable="true"]') != null; - this.#suppressFocusInFromDraggable = isDraggableAnnotation; - - if (annotationEl) { - if (!this.#editor.isEditable) { - return; - } - - const resolved = this.#resolveFieldAnnotationSelectionFromElement(annotationEl); - if (resolved) { - try { - const tr = this.#editor.state.tr.setSelection(NodeSelection.create(this.#editor.state.doc, resolved.pos)); - this.#editor.view?.dispatch(tr); - } catch {} - - this.#editor.emit('fieldAnnotationClicked', { - editor: this.#editor, - node: resolved.node, - nodePos: resolved.pos, - event, - currentTarget: annotationEl, - }); - } - return; - } - - if (!this.#layoutState.layout) { - // Layout not ready yet, but still focus the editor and set cursor to start - // so the user can immediately begin typing - if (!isDraggableAnnotation) { - event.preventDefault(); - } - - // Blur any currently focused element - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - - const editorDom = this.#editor.view?.dom as HTMLElement | undefined; - if (!editorDom) { - return; - } - - // Find the first valid text position in the document - const validPos = this.#getFirstTextPosition(); - const doc = this.#editor?.state?.doc; - - if (doc) { - try { - const tr = this.#editor.state.tr.setSelection(TextSelection.create(doc, validPos)); - this.#editor.view?.dispatch(tr); - } catch (error) { - // Error dispatching selection - this can happen if the document is in an invalid state - if (process.env.NODE_ENV === 'development') { - console.warn('[PresentationEditor] Failed to set selection to first text position:', error); - } - } - } - - // Focus the hidden editor - editorDom.focus(); - this.#editor.view?.focus(); - // Force selection update to render the caret - this.#scheduleSelectionUpdate(); - - return; - } - - const normalizedPoint = this.#normalizeClientPoint(event.clientX, event.clientY); - if (!normalizedPoint) { - return; - } - const { x, y } = normalizedPoint; - this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; - - // Exit header/footer mode if clicking outside the current region - const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; - if (sessionMode !== 'body') { - // Check if click is inside the active editor host element (more reliable than coordinate hit testing) - const activeEditorHost = this.#headerFooterSession?.overlayManager?.getActiveEditorHost?.(); - const clickedInsideEditorHost = - activeEditorHost && (activeEditorHost.contains(event.target as Node) || activeEditorHost === event.target); - - if (clickedInsideEditorHost) { - // Clicked within the active editor host - let the editor handle it, don't interfere - return; - } - - // Fallback: use coordinate-based hit testing - const headerFooterRegion = this.#hitTestHeaderFooterRegion(x, y); - if (!headerFooterRegion) { - // Clicked outside header/footer region - exit mode and continue to position cursor in body - this.#exitHeaderFooterMode(); - // Fall through to body click handling below - } else { - // Clicked within header/footer region but not in editor host - still let editor handle it - return; - } - } - - const headerFooterRegion = this.#hitTestHeaderFooterRegion(x, y); - if (headerFooterRegion) { - // Header/footer mode will be handled via double-click; ignore single clicks for now. - return; - } - - const rawHit = clickToPosition( - this.#layoutState.layout, - this.#layoutState.blocks, - this.#layoutState.measures, - { x, y }, - this.#viewportHost, - event.clientX, - event.clientY, - this.#pageGeometryHelper ?? undefined, - ); - - const doc = this.#editor.state?.doc; - const mapped = - rawHit && doc ? this.#epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1) : null; - if (mapped && !mapped.ok) { - debugLog('warn', 'pointerdown mapping failed', mapped); - } - const hit = - rawHit && doc && mapped?.ok - ? { ...rawHit, pos: Math.max(0, Math.min(mapped.pos, doc.content.size)), layoutEpoch: mapped.toEpoch } - : null; - this.#debugLastHit = hit - ? { source: 'dom', pos: rawHit?.pos ?? null, layoutEpoch: rawHit?.layoutEpoch ?? null, mappedPos: hit.pos } - : { source: 'none', pos: rawHit?.pos ?? null, layoutEpoch: rawHit?.layoutEpoch ?? null, mappedPos: null }; - this.#updateSelectionDebugHud(); - - // Don't preventDefault for draggable annotations - allows mousedown to fire for native drag - if (!isDraggableAnnotation) { - event.preventDefault(); - } - - // Even if clickToPosition returns null (clicked outside text content), - // we still want to focus the editor so the user can start typing - if (!rawHit) { - // Blur any currently focused element - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - - const editorDom = this.#editor.view?.dom as HTMLElement | undefined; - if (editorDom) { - // Find the first valid text position in the document - const validPos = this.#getFirstTextPosition(); - const doc = this.#editor?.state?.doc; - - if (doc) { - try { - const tr = this.#editor.state.tr.setSelection(TextSelection.create(doc, validPos)); - this.#editor.view?.dispatch(tr); - } catch (error) { - // Error dispatching selection - this can happen if the document is in an invalid state - if (process.env.NODE_ENV === 'development') { - console.warn('[PresentationEditor] Failed to set selection to first text position:', error); - } - } - } - editorDom.focus(); - this.#editor.view?.focus(); - // Force selection update to render the caret - this.#scheduleSelectionUpdate(); - } - return; - } - - if (!hit || !doc) { - // We got a layout position but couldn't map it to the current document deterministically. - // Keep the existing selection and allow the pending re-layout to catch up. - this.#pendingDocChange = true; - this.#scheduleRerender(); - return; - } - - // Check if click landed on an atomic fragment (image, drawing) - const fragmentHit = getFragmentAtPosition( - this.#layoutState.layout, - this.#layoutState.blocks, - this.#layoutState.measures, - rawHit.pos, - ); - - // Inline image hit detection via DOM target (for inline images rendered inside paragraphs) - const targetImg = (event.target as HTMLElement | null)?.closest?.('img'); - const imgPmStart = targetImg?.dataset?.pmStart ? Number(targetImg.dataset.pmStart) : null; - if (!Number.isNaN(imgPmStart) && imgPmStart != null) { - const doc = this.#editor.state.doc; - const imgLayoutEpochRaw = targetImg?.dataset?.layoutEpoch; - const imgLayoutEpoch = imgLayoutEpochRaw != null ? Number(imgLayoutEpochRaw) : NaN; - const rawLayoutEpoch = Number.isFinite(rawHit.layoutEpoch) ? rawHit.layoutEpoch : NaN; - const effectiveEpoch = - Number.isFinite(imgLayoutEpoch) && Number.isFinite(rawLayoutEpoch) - ? Math.max(imgLayoutEpoch, rawLayoutEpoch) - : Number.isFinite(imgLayoutEpoch) - ? imgLayoutEpoch - : rawHit.layoutEpoch; - const mappedImg = this.#epochMapper.mapPosFromLayoutToCurrentDetailed(imgPmStart, effectiveEpoch, 1); - if (!mappedImg.ok) { - debugLog('warn', 'inline image mapping failed', mappedImg); - this.#pendingDocChange = true; - this.#scheduleRerender(); - return; - } - const clampedImgPos = Math.max(0, Math.min(mappedImg.pos, doc.content.size)); - - // Validate position is within document bounds - if (clampedImgPos < 0 || clampedImgPos >= doc.content.size) { - if (process.env.NODE_ENV === 'development') { - console.warn( - `[PresentationEditor] Invalid position ${clampedImgPos} for inline image (document size: ${doc.content.size})`, - ); - } - return; - } - - // Emit imageDeselected if previous selection was a different image - const newSelectionId = `inline-${clampedImgPos}`; - if (this.#lastSelectedImageBlockId && this.#lastSelectedImageBlockId !== newSelectionId) { - this.emit('imageDeselected', { blockId: this.#lastSelectedImageBlockId } as ImageDeselectedEvent); - } - - try { - const tr = this.#editor.state.tr.setSelection(NodeSelection.create(doc, clampedImgPos)); - this.#editor.view?.dispatch(tr); - - const selector = `.superdoc-inline-image[data-pm-start="${imgPmStart}"]`; - const targetElement = this.#viewportHost.querySelector(selector); - this.emit('imageSelected', { - element: targetElement ?? targetImg, - blockId: null, - pmStart: clampedImgPos, - } as ImageSelectedEvent); - this.#lastSelectedImageBlockId = newSelectionId; - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn( - `[PresentationEditor] Failed to create NodeSelection for inline image at position ${imgPmStart}:`, - error, - ); - } - } - - this.#focusEditorAfterImageSelection(); - return; - } - - // If clicked on an atomic fragment (image or drawing), create NodeSelection - if (fragmentHit && (fragmentHit.fragment.kind === 'image' || fragmentHit.fragment.kind === 'drawing')) { - const doc = this.#editor.state.doc; - try { - // Create NodeSelection for atomic node at hit position - const tr = this.#editor.state.tr.setSelection(NodeSelection.create(doc, hit.pos)); - this.#editor.view?.dispatch(tr); - - // Emit imageDeselected if previous selection was a different image - if (this.#lastSelectedImageBlockId && this.#lastSelectedImageBlockId !== fragmentHit.fragment.blockId) { - this.emit('imageDeselected', { blockId: this.#lastSelectedImageBlockId } as ImageDeselectedEvent); - } - - // Emit imageSelected event for overlay to detect - if (fragmentHit.fragment.kind === 'image') { - const targetElement = this.#viewportHost.querySelector( - `.superdoc-image-fragment[data-pm-start="${fragmentHit.fragment.pmStart}"]`, - ); - if (targetElement) { - this.emit('imageSelected', { - element: targetElement, - blockId: fragmentHit.fragment.blockId, - pmStart: fragmentHit.fragment.pmStart, - } as ImageSelectedEvent); - this.#lastSelectedImageBlockId = fragmentHit.fragment.blockId; - } - } - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('[PresentationEditor] Failed to create NodeSelection for atomic fragment:', error); - } - } - - this.#focusEditorAfterImageSelection(); - return; - } - - // If clicking away from an image, emit imageDeselected - if (this.#lastSelectedImageBlockId) { - this.emit('imageDeselected', { blockId: this.#lastSelectedImageBlockId } as ImageDeselectedEvent); - this.#lastSelectedImageBlockId = null; - } - - // Handle shift+click to extend selection - if (event.shiftKey && this.#editor.state.selection.$anchor) { - const anchor = this.#editor.state.selection.anchor; - const head = hit.pos; - - // Use current extension mode (from previous double/triple click) or default to character mode - const { selAnchor, selHead } = this.#calculateExtendedSelection(anchor, head, this.#dragExtensionMode); - - try { - const tr = this.#editor.state.tr.setSelection(TextSelection.create(this.#editor.state.doc, selAnchor, selHead)); - this.#editor.view?.dispatch(tr); - this.#scheduleSelectionUpdate(); - } catch (error) { - console.warn('[SELECTION] Failed to extend selection on shift+click:', { - error, - anchor, - head, - selAnchor, - selHead, - mode: this.#dragExtensionMode, - }); - } - - // Focus editor - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - const editorDom = this.#editor.view?.dom as HTMLElement | undefined; - if (editorDom) { - editorDom.focus(); - this.#editor.view?.focus(); - } - - return; // Don't start drag on shift+click - } - - const clickDepth = this.#registerPointerClick(event); - - // Set up drag selection state - // Only update dragAnchor on single click; preserve it for double/triple clicks - // so word/paragraph selection uses the consistent first-click position - // (the second click can return a slightly different position due to mouse movement) - if (clickDepth === 1) { - this.#dragAnchor = hit.pos; - this.#dragAnchorPageIndex = hit.pageIndex; - this.#pendingMarginClick = this.#computePendingMarginClick(event.pointerId, x, y); - - // Check if click is inside a table cell for potential cell selection - // Only set up cell anchor on single click (not double/triple for word/para selection) - const tableHit = this.#hitTestTable(x, y); - - if (tableHit) { - const tablePos = this.#getTablePosFromHit(tableHit); - if (tablePos !== null) { - this.#setCellAnchor(tableHit, tablePos); - } - } else { - // Clicked outside table - clear any existing cell anchor - this.#clearCellAnchor(); - } - } else { - this.#pendingMarginClick = null; - } - - this.#dragLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; - this.#dragLastRawHit = hit; - this.#dragUsedPageNotMountedFallback = false; - - this.#isDragging = true; - if (clickDepth >= 3) { - this.#dragExtensionMode = 'para'; - } else if (clickDepth === 2) { - this.#dragExtensionMode = 'word'; - } else { - this.#dragExtensionMode = 'char'; - } - - debugLog( - 'verbose', - `Drag selection start ${JSON.stringify({ - pointer: { clientX: event.clientX, clientY: event.clientY, x, y }, - clickDepth, - extensionMode: this.#dragExtensionMode, - anchor: this.#dragAnchor, - anchorPageIndex: this.#dragAnchorPageIndex, - rawHit: rawHit - ? { - pos: rawHit.pos, - pageIndex: rawHit.pageIndex, - blockId: rawHit.blockId, - lineIndex: rawHit.lineIndex, - layoutEpoch: rawHit.layoutEpoch, - } - : null, - mapped: mapped - ? mapped.ok - ? { ok: true, pos: mapped.pos, fromEpoch: mapped.fromEpoch, toEpoch: mapped.toEpoch } - : { - ok: false, - reason: (mapped as { ok: false; reason: string }).reason, - fromEpoch: mapped.fromEpoch, - toEpoch: mapped.toEpoch, - } - : null, - hit: hit ? { pos: hit.pos, pageIndex: hit.pageIndex, layoutEpoch: hit.layoutEpoch } : null, - })}`, - ); - - // Capture pointer for reliable drag tracking even outside viewport - // Guard for test environments where setPointerCapture may not exist - if (typeof this.#viewportHost.setPointerCapture === 'function') { - this.#viewportHost.setPointerCapture(event.pointerId); - } - - let handledByDepth = false; - const sessionModeForDepth = this.#headerFooterSession?.session?.mode ?? 'body'; - if (sessionModeForDepth === 'body') { - // For double/triple clicks, use the stored dragAnchor from the first click - // to avoid position drift from slight mouse movement between clicks - const selectionPos = clickDepth >= 2 && this.#dragAnchor !== null ? this.#dragAnchor : hit.pos; - - if (clickDepth >= 3) { - handledByDepth = this.#selectParagraphAt(selectionPos); - } else if (clickDepth === 2) { - handledByDepth = this.#selectWordAt(selectionPos); - } - } - - if (!handledByDepth) { - try { - const doc = this.#editor.state.doc; - let nextSelection: Selection = TextSelection.create(doc, hit.pos); - if (!nextSelection.$from.parent.inlineContent) { - nextSelection = Selection.near(doc.resolve(hit.pos), 1); - } - const tr = this.#editor.state.tr.setSelection(nextSelection); - this.#editor.view?.dispatch(tr); - } catch { - // Position may be invalid during layout updates (e.g., after drag-drop) - ignore - } - } - - // Force selection update to clear stale carets even if PM thinks selection didn't change. - // This handles clicking at/near same position where PM's selection.eq() might return true, - // which prevents 'selectionUpdate' event from firing and leaves old carets on screen. - // By forcing the update, we ensure #updateSelection() runs and clears the DOM layer. - this.#scheduleSelectionUpdate(); - - // Blur any currently focused element to ensure the PM editor can receive focus - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - - const editorDom = this.#editor.view?.dom as HTMLElement | undefined; - if (!editorDom) { - return; - } - - // Try direct DOM focus first - editorDom.focus(); - this.#editor.view?.focus(); - }; - - /** - * Finds the first valid text position in the document. - * - * Traverses the document tree to locate the first textblock node (paragraph, heading, etc.) - * and returns a position inside it. This is used when focusing the editor but no specific - * position is available (e.g., clicking outside text content or before layout is ready). - * - * @returns The position inside the first textblock, or 1 if no textblock is found - * @private - */ - #getFirstTextPosition(): number { - return getFirstTextPositionFromHelper(this.#editor?.state?.doc ?? null); - } - - /** - * Registers a pointer click event and tracks multi-click sequences (double, triple). - * - * This method implements multi-click detection by tracking the timing and position - * of consecutive clicks. Clicks within 400ms and 5px of each other increment the - * click count, up to a maximum of 3 (single, double, triple). - * - * @param event - The mouse event from the pointer down handler - * @returns The current click count (1 = single, 2 = double, 3 = triple) - * @private - */ - #registerPointerClick(event: MouseEvent): number { - const nextState = registerPointerClickFromHelper( - event, - { clickCount: this.#clickCount, lastClickTime: this.#lastClickTime, lastClickPosition: this.#lastClickPosition }, - { - timeThresholdMs: MULTI_CLICK_TIME_THRESHOLD_MS, - distanceThresholdPx: MULTI_CLICK_DISTANCE_THRESHOLD_PX, - maxClickCount: 3, - }, - ); - - this.#clickCount = nextState.clickCount; - this.#lastClickTime = nextState.lastClickTime; - this.#lastClickPosition = nextState.lastClickPosition; - - return nextState.clickCount; - } - - // ============================================================================ - // Cell Selection Utilities - // ============================================================================ - - /** - * Gets the ProseMirror position at the start of a table cell from a table hit result. - * - * This method navigates the ProseMirror document structure to find the exact position where - * a table cell begins. The position returned is suitable for use with CellSelection.create(). - * - * Algorithm: - * 1. Validate input (tableHit structure and cell indices) - * 2. Traverse document to find the table node matching tableHit.block.id - * 3. Navigate through table structure (table > row > cell) to target row - * 4. Track logical column position accounting for colspan (handles merged cells) - * 5. Return position when target column falls within a cell's span - * - * Merged cell handling: - * - Does NOT assume 1:1 mapping between cell index and logical column - * - Tracks cumulative logical column position by summing colspan values - * - A cell with colspan=3 occupies logical columns [n, n+1, n+2] - * - Finds the cell whose logical span contains the target column index - * - * Error handling: - * - Input validation with console warnings for debugging - * - Try-catch around document traversal (catches corrupted document errors) - * - Bounds checking for row indices - * - Null checks at each navigation step - * - * @param tableHit - The table hit result from hitTestTableFragment containing: - * - block: TableBlock with the table's block ID - * - cellRowIndex: 0-based row index of the target cell - * - cellColIndex: 0-based logical column index of the target cell - * @returns The PM position at the start of the cell, or null if: - * - Invalid input (null tableHit, negative indices) - * - Table not found in document - * - Target row out of bounds - * - Target column not found in row - * - Document traversal error - * @private - * - * @throws Never throws - all errors are caught and logged, returns null on failure - */ - #getCellPosFromTableHit(tableHit: TableHitResult): number | null { - return getCellPosFromTableHitFromHelper(tableHit, this.#editor.state?.doc ?? null, this.#layoutState.blocks); - } - - /** - * Gets the table position (start of table node) from a table hit result. - * - * @param tableHit - The table hit result from hitTestTableFragment - * @returns The PM position at the start of the table, or null if not found - * @private - */ - #getTablePosFromHit(tableHit: TableHitResult): number | null { - return getTablePosFromHitFromHelper(tableHit, this.#editor.state?.doc ?? null, this.#layoutState.blocks); - } - - /** - * Determines if the current drag should create a CellSelection instead of TextSelection. - * - * Implements a state machine for table cell selection: - * - 'none': Not in a table, use TextSelection - * - 'pending': Started drag in a table, but haven't crossed cell boundary yet - * - 'active': Crossed cell boundary, use CellSelection - * - * State transitions: - * - none → pending: When drag starts in a table cell (#setCellAnchor) - * - pending → active: When drag crosses into a different cell (this method returns true) - * - active → none: When drag ends (#clearCellAnchor) - * - * → none: When document changes or clicking outside table - * - * Decision logic: - * 1. No cell anchor → false (not in table drag mode) - * 2. Current position outside table → return current state (stay in 'active' if already there) - * 3. Different table → treat as outside table - * 4. Different cell in same table → true (activate cell selection) - * 5. Same cell → return current state (stay in 'active' if already there, else false) - * - * This state machine ensures: - * - Text selection works normally within a single cell - * - Cell selection activates smoothly when crossing cell boundaries - * - Once activated, cell selection persists even if dragging back to anchor cell - * - * @param currentTableHit - The table hit result for the current pointer position, or null if not in a table - * @returns true if we should create a CellSelection, false for TextSelection - * @private - */ - #shouldUseCellSelection(currentTableHit: TableHitResult | null): boolean { - return shouldUseCellSelectionFromHelper(currentTableHit, this.#cellAnchor, this.#cellDragMode); - } - - /** - * Stores the cell anchor when a drag operation starts inside a table cell. - * - * @param tableHit - The table hit result for the initial click position - * @param tablePos - The PM position of the table node - * @private - */ - #setCellAnchor(tableHit: TableHitResult, tablePos: number): void { - const cellPos = this.#getCellPosFromTableHit(tableHit); - if (cellPos === null) { - return; - } - - this.#cellAnchor = { - tablePos, - cellPos, - cellRowIndex: tableHit.cellRowIndex, - cellColIndex: tableHit.cellColIndex, - tableBlockId: tableHit.block.id, - }; - this.#cellDragMode = 'pending'; - } - - /** - * Clears the cell drag state. - * Called when drag ends or when clicking outside a table. - * - * @private - */ - #clearCellAnchor(): void { - this.#cellAnchor = null; - this.#cellDragMode = 'none'; - } - /** * Attempts to perform a table hit test for the given normalized coordinates. * @@ -3351,516 +2690,6 @@ export class PresentationEditor extends EventEmitter { return calculateExtendedSelection(this.#layoutState.blocks, anchor, head, mode); } - #handlePointerMove = (event: PointerEvent) => { - if (!this.#layoutState.layout) return; - const normalized = this.#normalizeClientPoint(event.clientX, event.clientY); - if (!normalized) return; - - // Handle drag selection when button is held - if (this.#isDragging && this.#dragAnchor !== null && event.buttons & 1) { - this.#pendingMarginClick = null; - const prevPointer = this.#dragLastPointer; - const prevRawHit = this.#dragLastRawHit; - this.#dragLastPointer = { clientX: event.clientX, clientY: event.clientY, x: normalized.x, y: normalized.y }; - const rawHit = clickToPosition( - this.#layoutState.layout, - this.#layoutState.blocks, - this.#layoutState.measures, - { x: normalized.x, y: normalized.y }, - this.#viewportHost, - event.clientX, - event.clientY, - this.#pageGeometryHelper ?? undefined, - ); - - // If we can't find a position, keep the last selection - if (!rawHit) { - debugLog( - 'verbose', - `Drag selection update (no hit) ${JSON.stringify({ - pointer: { clientX: event.clientX, clientY: event.clientY, x: normalized.x, y: normalized.y }, - prevPointer, - anchor: this.#dragAnchor, - })}`, - ); - return; - } - - const doc = this.#editor.state?.doc; - if (!doc) return; - - this.#dragLastRawHit = rawHit; - const pageMounted = this.#getPageElement(rawHit.pageIndex) != null; - if (!pageMounted && this.#isSelectionAwareVirtualizationEnabled()) { - this.#dragUsedPageNotMountedFallback = true; - debugLog('warn', 'Geometry fallback', { reason: 'page_not_mounted', pageIndex: rawHit.pageIndex }); - } - this.#updateSelectionVirtualizationPins({ includeDragBuffer: true, extraPages: [rawHit.pageIndex] }); - - const mappedHead = this.#epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1); - if (!mappedHead.ok) { - debugLog('warn', 'drag mapping failed', mappedHead); - debugLog( - 'verbose', - `Drag selection update (map failed) ${JSON.stringify({ - pointer: { clientX: event.clientX, clientY: event.clientY, x: normalized.x, y: normalized.y }, - prevPointer, - anchor: this.#dragAnchor, - rawHit: { - pos: rawHit.pos, - pageIndex: rawHit.pageIndex, - blockId: rawHit.blockId, - lineIndex: rawHit.lineIndex, - layoutEpoch: rawHit.layoutEpoch, - }, - mapped: { - ok: false, - reason: (mappedHead as { ok: false; reason: string }).reason, - fromEpoch: mappedHead.fromEpoch, - toEpoch: mappedHead.toEpoch, - }, - })}`, - ); - return; - } - - const hit = { - ...rawHit, - pos: Math.max(0, Math.min(mappedHead.pos, doc.content.size)), - layoutEpoch: mappedHead.toEpoch, - }; - this.#debugLastHit = { - source: pageMounted ? 'dom' : 'geometry', - pos: rawHit.pos, - layoutEpoch: rawHit.layoutEpoch, - mappedPos: hit.pos, - }; - this.#updateSelectionDebugHud(); - - const anchor = this.#dragAnchor; - const head = hit.pos; - const { selAnchor, selHead } = this.#calculateExtendedSelection(anchor, head, this.#dragExtensionMode); - debugLog( - 'verbose', - `Drag selection update ${JSON.stringify({ - pointer: { clientX: event.clientX, clientY: event.clientY, x: normalized.x, y: normalized.y }, - prevPointer, - rawHit: { - pos: rawHit.pos, - pageIndex: rawHit.pageIndex, - blockId: rawHit.blockId, - lineIndex: rawHit.lineIndex, - layoutEpoch: rawHit.layoutEpoch, - }, - prevRawHit: prevRawHit - ? { - pos: prevRawHit.pos, - pageIndex: prevRawHit.pageIndex, - blockId: prevRawHit.blockId, - lineIndex: prevRawHit.lineIndex, - layoutEpoch: prevRawHit.layoutEpoch, - } - : null, - mappedHead: { pos: mappedHead.pos, fromEpoch: mappedHead.fromEpoch, toEpoch: mappedHead.toEpoch }, - hit: { pos: hit.pos, pageIndex: hit.pageIndex, layoutEpoch: hit.layoutEpoch }, - anchor, - head, - selAnchor, - selHead, - direction: head >= anchor ? 'down' : 'up', - selectionDirection: selHead >= selAnchor ? 'down' : 'up', - extensionMode: this.#dragExtensionMode, - hitSource: pageMounted ? 'dom' : 'geometry', - pageMounted, - })}`, - ); - - // Check for cell selection mode (table drag) - const currentTableHit = this.#hitTestTable(normalized.x, normalized.y); - const shouldUseCellSel = this.#shouldUseCellSelection(currentTableHit); - - if (shouldUseCellSel && this.#cellAnchor) { - // Cell selection mode - create CellSelection spanning anchor to current cell - const headCellPos = currentTableHit ? this.#getCellPosFromTableHit(currentTableHit) : null; - - if (headCellPos !== null) { - // Transition to active mode if we weren't already - if (this.#cellDragMode !== 'active') { - this.#cellDragMode = 'active'; - } - - try { - const doc = this.#editor.state.doc; - const anchorCellPos = this.#cellAnchor.cellPos; - - // Validate positions are within document bounds - const clampedAnchor = Math.max(0, Math.min(anchorCellPos, doc.content.size)); - const clampedHead = Math.max(0, Math.min(headCellPos, doc.content.size)); - - const cellSelection = CellSelection.create(doc, clampedAnchor, clampedHead); - const tr = this.#editor.state.tr.setSelection(cellSelection); - this.#editor.view?.dispatch(tr); - this.#scheduleSelectionUpdate(); - } catch (error) { - // CellSelection creation can fail if positions are invalid - // Fall back to text selection - console.warn('[CELL-SELECTION] Failed to create CellSelection, falling back to TextSelection:', error); - - const anchor = this.#dragAnchor; - const head = hit.pos; - const { selAnchor, selHead } = this.#calculateExtendedSelection(anchor, head, this.#dragExtensionMode); - - try { - const tr = this.#editor.state.tr.setSelection( - TextSelection.create(this.#editor.state.doc, selAnchor, selHead), - ); - this.#editor.view?.dispatch(tr); - this.#scheduleSelectionUpdate(); - } catch { - // Position may be invalid during layout updates - ignore - } - } - - return; // Skip header/footer hover logic during drag - } - } - - // Text selection mode (default) - // Apply extension mode to expand selection boundaries, preserving direction - - try { - const tr = this.#editor.state.tr.setSelection(TextSelection.create(this.#editor.state.doc, selAnchor, selHead)); - this.#editor.view?.dispatch(tr); - this.#scheduleSelectionUpdate(); - } catch (error) { - console.warn('[SELECTION] Failed to extend selection during drag:', { - error, - anchor, - head, - selAnchor, - selHead, - mode: this.#dragExtensionMode, - }); - } - - return; // Skip header/footer hover logic during drag - } - - const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; - if (sessionMode !== 'body') { - this.#clearHoverRegion(); - return; - } - if (this.#documentMode === 'viewing') { - this.#clearHoverRegion(); - return; - } - const region = this.#hitTestHeaderFooterRegion(normalized.x, normalized.y); - if (!region) { - this.#clearHoverRegion(); - return; - } - const currentHover = this.#headerFooterSession?.hoverRegion; - if ( - currentHover && - currentHover.kind === region.kind && - currentHover.pageIndex === region.pageIndex && - currentHover.sectionType === region.sectionType - ) { - return; - } - this.#headerFooterSession?.renderHover(region); - this.#renderHoverRegion(region); - }; - - #handlePointerLeave = () => { - this.#clearHoverRegion(); - }; - - #handleVisibleHostFocusIn = (event: FocusEvent) => { - // Avoid stealing focus from toolbars/dropdowns registered as UI surfaces. - if (isInRegisteredSurface(event)) { - return; - } - - if (this.#suppressFocusInFromDraggable) { - this.#suppressFocusInFromDraggable = false; - return; - } - - const target = event.target as Node | null; - const activeTarget = this.#getActiveDomTarget(); - if (!activeTarget) { - return; - } - - const activeNode = activeTarget as unknown as Node; - const containsFn = - typeof (activeNode as { contains?: (node: Node | null) => boolean }).contains === 'function' - ? (activeNode as { contains: (node: Node | null) => boolean }).contains - : null; - - if (target && (activeNode === target || (containsFn && containsFn.call(activeNode, target)))) { - return; - } - - try { - if (activeTarget instanceof HTMLElement && typeof activeTarget.focus === 'function') { - // preventScroll supported in modern browsers; fall back silently when not. - (activeTarget as unknown as { focus?: (opts?: { preventScroll?: boolean }) => void }).focus?.({ - preventScroll: true, - }); - } else if (typeof (activeTarget as { focus?: () => void }).focus === 'function') { - (activeTarget as { focus: () => void }).focus(); - } - } catch { - // Ignore focus failures (e.g., non-focusable targets in headless tests) - } - - try { - this.getActiveEditor().view?.focus(); - } catch { - // Ignore focus failures - } - }; - - #handlePointerUp = (event: PointerEvent) => { - this.#suppressFocusInFromDraggable = false; - - if (!this.#isDragging) return; - - // Release pointer capture if we have it - // Guard for test environments where pointer capture methods may not exist - if ( - typeof this.#viewportHost.hasPointerCapture === 'function' && - typeof this.#viewportHost.releasePointerCapture === 'function' && - this.#viewportHost.hasPointerCapture(event.pointerId) - ) { - this.#viewportHost.releasePointerCapture(event.pointerId); - } - - const pendingMarginClick = this.#pendingMarginClick; - this.#pendingMarginClick = null; - - const dragAnchor = this.#dragAnchor; - const dragMode = this.#dragExtensionMode; - const dragUsedFallback = this.#dragUsedPageNotMountedFallback; - const dragPointer = this.#dragLastPointer; - - // Clear drag state - but preserve #dragAnchor and #dragExtensionMode - // because they're needed for double-click word selection (the anchor from - // the first click must persist to the second click) and for shift+click - // to extend selection in the same mode (word/para) after a multi-click - this.#isDragging = false; - - // Reset cell drag mode but preserve #cellAnchor for potential shift+click extension - // If we were in active cell selection mode, the CellSelection is already dispatched - // and preserved in the editor state - if (this.#cellDragMode !== 'none') { - this.#cellDragMode = 'none'; - } - - if (!pendingMarginClick || pendingMarginClick.pointerId !== event.pointerId) { - // End of drag selection (non-margin). Drop drag buffer pages and keep endpoints mounted. - this.#updateSelectionVirtualizationPins({ includeDragBuffer: false }); - - if (dragUsedFallback && dragAnchor != null) { - const pointer = dragPointer ?? { clientX: event.clientX, clientY: event.clientY }; - this.#finalizeDragSelectionWithDom(pointer, dragAnchor, dragMode); - } - - this.#scheduleA11ySelectionAnnouncement({ immediate: true }); - - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - return; - } - const sessionModeForDrag = this.#headerFooterSession?.session?.mode ?? 'body'; - if (sessionModeForDrag !== 'body' || this.#isViewLocked()) { - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - return; - } - - const doc = this.#editor.state?.doc; - if (!doc) { - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - return; - } - - if (pendingMarginClick.kind === 'aboveFirstLine') { - const pos = this.#getFirstTextPosition(); - try { - const tr = this.#editor.state.tr.setSelection(TextSelection.create(doc, pos)); - this.#editor.view?.dispatch(tr); - this.#scheduleSelectionUpdate(); - } catch { - // Ignore invalid positions during re-layout - } - this.#debugLastHit = { source: 'margin', pos: null, layoutEpoch: null, mappedPos: pos }; - this.#updateSelectionDebugHud(); - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - return; - } - - if (pendingMarginClick.kind === 'right') { - const mappedEnd = this.#epochMapper.mapPosFromLayoutToCurrentDetailed( - pendingMarginClick.pmEnd, - pendingMarginClick.layoutEpoch, - 1, - ); - if (!mappedEnd.ok) { - debugLog('warn', 'right margin mapping failed', mappedEnd); - this.#pendingDocChange = true; - this.#scheduleRerender(); - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - return; - } - const caretPos = Math.max(0, Math.min(mappedEnd.pos, doc.content.size)); - try { - const tr = this.#editor.state.tr.setSelection(TextSelection.create(doc, caretPos)); - this.#editor.view?.dispatch(tr); - this.#scheduleSelectionUpdate(); - } catch { - // Ignore invalid positions during re-layout - } - this.#debugLastHit = { - source: 'margin', - pos: pendingMarginClick.pmEnd, - layoutEpoch: pendingMarginClick.layoutEpoch, - mappedPos: caretPos, - }; - this.#updateSelectionDebugHud(); - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - return; - } - - const mappedStart = this.#epochMapper.mapPosFromLayoutToCurrentDetailed( - pendingMarginClick.pmStart, - pendingMarginClick.layoutEpoch, - 1, - ); - const mappedEnd = this.#epochMapper.mapPosFromLayoutToCurrentDetailed( - pendingMarginClick.pmEnd, - pendingMarginClick.layoutEpoch, - -1, - ); - if (!mappedStart.ok || !mappedEnd.ok) { - if (!mappedStart.ok) debugLog('warn', 'left margin mapping failed (start)', mappedStart); - if (!mappedEnd.ok) debugLog('warn', 'left margin mapping failed (end)', mappedEnd); - this.#pendingDocChange = true; - this.#scheduleRerender(); - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - return; - } - - const selFrom = Math.max(0, Math.min(Math.min(mappedStart.pos, mappedEnd.pos), doc.content.size)); - const selTo = Math.max(0, Math.min(Math.max(mappedStart.pos, mappedEnd.pos), doc.content.size)); - try { - const tr = this.#editor.state.tr.setSelection(TextSelection.create(doc, selFrom, selTo)); - this.#editor.view?.dispatch(tr); - this.#scheduleSelectionUpdate(); - } catch { - // Ignore invalid positions during re-layout - } - this.#debugLastHit = { - source: 'margin', - pos: pendingMarginClick.pmStart, - layoutEpoch: pendingMarginClick.layoutEpoch, - mappedPos: selFrom, - }; - this.#updateSelectionDebugHud(); - - this.#dragLastPointer = null; - this.#dragLastRawHit = null; - this.#dragUsedPageNotMountedFallback = false; - }; - - #handleDragOver = createExternalFieldAnnotationDragOverHandler({ - getActiveEditor: () => this.getActiveEditor(), - hitTest: (clientX, clientY) => this.hitTest(clientX, clientY), - scheduleSelectionUpdate: () => this.#scheduleSelectionUpdate(), - }); - - #handleDrop = createExternalFieldAnnotationDropHandler({ - getActiveEditor: () => this.getActiveEditor(), - hitTest: (clientX, clientY) => this.hitTest(clientX, clientY), - scheduleSelectionUpdate: () => this.#scheduleSelectionUpdate(), - }); - - #handleDoubleClick = (event: MouseEvent) => { - if (event.button !== 0) return; - if (!this.#layoutState.layout) return; - - const rect = this.#viewportHost.getBoundingClientRect(); - // Use effective zoom from actual rendered dimensions for accurate coordinate conversion - const zoom = this.#layoutOptions.zoom ?? 1; - const scrollLeft = this.#visibleHost.scrollLeft ?? 0; - const scrollTop = this.#visibleHost.scrollTop ?? 0; - const x = (event.clientX - rect.left + scrollLeft) / zoom; - const y = (event.clientY - rect.top + scrollTop) / zoom; - - const region = this.#hitTestHeaderFooterRegion(x, y); - if (region) { - event.preventDefault(); - event.stopPropagation(); - - // Check if header/footer exists, create if not - const descriptor = this.#resolveDescriptorForRegion(region); - const hfManager = this.#headerFooterSession?.manager; - if (!descriptor && hfManager) { - // No header/footer exists - create a default one - this.#createDefaultHeaderFooter(region); - // Refresh the manager to pick up the new descriptor - hfManager.refresh(); - } - - this.#activateHeaderFooterRegion(region); - } else if ((this.#headerFooterSession?.session?.mode ?? 'body') !== 'body') { - this.#exitHeaderFooterMode(); - } - }; - - #handleKeyDown = (event: KeyboardEvent) => { - const sessionModeForKey = this.#headerFooterSession?.session?.mode ?? 'body'; - if (event.key === 'Escape' && sessionModeForKey !== 'body') { - event.preventDefault(); - this.#exitHeaderFooterMode(); - return; - } - if (event.ctrlKey && event.altKey && !event.shiftKey) { - if (event.code === 'KeyH') { - event.preventDefault(); - this.#focusHeaderFooterShortcut('header'); - } else if (event.code === 'KeyF') { - event.preventDefault(); - this.#focusHeaderFooterShortcut('footer'); - } - } - }; - - #focusHeaderFooterShortcut(kind: 'header' | 'footer') { - const pageIndex = this.#getCurrentPageIndex(); - const region = this.#findRegionForPage(kind, pageIndex); - if (!region) { - this.#emitHeaderFooterEditBlocked('missingRegion'); - return; - } - this.#activateHeaderFooterRegion(region); - } - #scheduleRerender() { if (this.#renderScheduled) { return; @@ -4426,7 +3255,7 @@ export class PresentationEditor extends EventEmitter { // Ensure selection endpoints remain mounted under virtualization so DOM-first // caret/selection rendering stays available during cross-page selection. - this.#updateSelectionVirtualizationPins({ includeDragBuffer: this.#isDragging }); + this.#updateSelectionVirtualizationPins({ includeDragBuffer: this.#editorInputManager?.isDragging ?? false }); // Handle CellSelection - render cell backgrounds for selected table cells if (selection instanceof CellSelection) { @@ -5445,7 +4274,7 @@ export class PresentationEditor extends EventEmitter { { ariaLiveRegion: this.#ariaLiveRegion, sessionMode, - isDragging: this.#isDragging, + isDragging: this.#editorInputManager?.isDragging ?? false, visibleHost: this.#visibleHost, currentTimeout: this.#a11ySelectionAnnounceTimeout, announceNow: () => { @@ -5590,9 +4419,9 @@ export class PresentationEditor extends EventEmitter { : null, docSize, includeDragBuffer: Boolean(options?.includeDragBuffer), - isDragging: this.#isDragging, - dragAnchorPageIndex: this.#dragAnchorPageIndex, - dragLastHitPageIndex: this.#dragLastRawHit ? this.#dragLastRawHit.pageIndex : null, + isDragging: this.#editorInputManager?.isDragging ?? false, + dragAnchorPageIndex: this.#editorInputManager?.dragAnchorPageIndex ?? null, + dragLastHitPageIndex: this.#editorInputManager?.dragLastHitPageIndex ?? null, extraPages: options?.extraPages, }); @@ -5616,9 +4445,10 @@ export class PresentationEditor extends EventEmitter { if (!normalized) return; // Ensure endpoint pages are pinned so DOM hit testing can resolve without scrolling. + const dragLastRawHit = this.#editorInputManager?.dragLastRawHit; this.#updateSelectionVirtualizationPins({ includeDragBuffer: false, - extraPages: this.#dragLastRawHit ? [this.#dragLastRawHit.pageIndex] : undefined, + extraPages: dragLastRawHit ? [dragLastRawHit.pageIndex] : undefined, }); const refined = clickToPosition( @@ -5638,7 +4468,7 @@ export class PresentationEditor extends EventEmitter { return; } - const prior = this.#dragLastRawHit; + const prior = dragLastRawHit; if (prior && (prior.pos !== refined.pos || prior.pageIndex !== refined.pageIndex)) { debugLog('info', 'Drag finalize refined hit', { fromPos: prior.pos, @@ -5868,7 +4698,7 @@ export class PresentationEditor extends EventEmitter { localSelectionLayer, blocks: this.#layoutState.blocks, measures: this.#layoutState.measures, - cellAnchorTableBlockId: this.#cellAnchor?.tableBlockId ?? null, + cellAnchorTableBlockId: this.#editorInputManager?.cellAnchor?.tableBlockId ?? null, convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), }); } diff --git a/packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts b/packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts new file mode 100644 index 000000000..47be10bbc --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/input/DragDropManager.ts @@ -0,0 +1,522 @@ +/** + * 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) + * - Window-level fallback for drops on overlay elements + */ + +import { TextSelection } from 'prosemirror-state'; +import type { Editor } from '../../Editor.js'; +import type { PositionHit } from '@superdoc/layout-bridge'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** MIME type for internal field annotation drag operations */ +const INTERNAL_MIME_TYPE = 'application/x-field-annotation'; + +/** MIME type for external field annotation drag operations (legacy compatibility) */ +export const FIELD_ANNOTATION_DATA_TYPE = 'fieldAnnotation' as const; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Attributes for a field annotation node. + */ +export interface FieldAnnotationAttributes { + fieldId: string; + fieldType: string; + displayLabel: string; + type: string; + fieldColor?: string; +} + +/** + * Information about the source field being dragged. + */ +export interface SourceFieldInfo { + fieldId: string; + fieldType: string; + annotationType: string; +} + +/** + * Payload structure for field annotation drag-and-drop data. + */ +export interface FieldAnnotationDragPayload { + attributes?: FieldAnnotationAttributes; + sourceField?: SourceFieldInfo; +} + +/** + * Data extracted from a draggable field annotation element. + */ +export interface FieldAnnotationDragData { + fieldId?: string; + fieldType?: string; + variant?: string; + displayLabel?: string; + pmStart?: number; + pmEnd?: number; + attributes?: Record; +} + +/** + * Dependencies injected from PresentationEditor. + */ +export type DragDropDependencies = { + /** Get the active editor (body or header/footer) */ + getActiveEditor: () => Editor; + /** Hit test to convert client coordinates to ProseMirror position */ + hitTest: (clientX: number, clientY: number) => PositionHit | null; + /** Schedule selection overlay update */ + scheduleSelectionUpdate: () => void; + /** The viewport host element (for event listeners) */ + getViewportHost: () => HTMLElement; + /** The painter host element (for internal drag detection) */ + getPainterHost: () => HTMLElement; +}; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Type guard to validate field annotation attributes. + */ +export function isValidFieldAnnotationAttributes(attrs: unknown): attrs is FieldAnnotationAttributes { + if (!attrs || typeof attrs !== 'object') return false; + const a = attrs as Record; + return ( + typeof a.fieldId === 'string' && + typeof a.fieldType === 'string' && + typeof a.displayLabel === 'string' && + typeof a.type === 'string' + ); +} + +/** + * Safely parses an integer from a string. + */ +function parseIntSafe(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** + * Extracts field annotation data from a draggable element's dataset. + */ +function extractFieldAnnotationData(element: HTMLElement): FieldAnnotationDragData { + const dataset = element.dataset; + const attributes: Record = {}; + for (const key in dataset) { + const value = dataset[key]; + if (value !== undefined) { + attributes[key] = value; + } + } + + return { + fieldId: dataset.fieldId, + fieldType: dataset.fieldType, + variant: dataset.variant ?? dataset.type, + displayLabel: dataset.displayLabel, + pmStart: parseIntSafe(dataset.pmStart), + pmEnd: parseIntSafe(dataset.pmEnd), + attributes, + }; +} + +/** + * Checks if a drag event contains field annotation data. + */ +function hasFieldAnnotationData(event: DragEvent): boolean { + if (!event.dataTransfer) return false; + const types = event.dataTransfer.types; + return types.includes(INTERNAL_MIME_TYPE) || types.includes(FIELD_ANNOTATION_DATA_TYPE); +} + +/** + * Checks if a drag event is an internal drag (from within the editor). + */ +function isInternalDrag(event: DragEvent): boolean { + return event.dataTransfer?.types?.includes(INTERNAL_MIME_TYPE) ?? false; +} + +/** + * Extracts field annotation data from a drag event's dataTransfer. + */ +function extractDragData(event: DragEvent): FieldAnnotationDragData | null { + if (!event.dataTransfer) return null; + + let jsonData = event.dataTransfer.getData(INTERNAL_MIME_TYPE); + if (!jsonData) { + jsonData = event.dataTransfer.getData(FIELD_ANNOTATION_DATA_TYPE); + } + if (!jsonData) return null; + + try { + const parsed = JSON.parse(jsonData); + return parsed.sourceField ?? parsed.attributes ?? parsed; + } catch { + return null; + } +} + +// ============================================================================= +// DragDropManager Class +// ============================================================================= + +export class DragDropManager { + #deps: DragDropDependencies | null = null; + + // Bound handlers for cleanup + #boundHandleDragStart: ((e: DragEvent) => void) | null = null; + #boundHandleDragOver: ((e: DragEvent) => void) | null = null; + #boundHandleDrop: ((e: DragEvent) => void) | null = null; + #boundHandleDragEnd: ((e: DragEvent) => void) | null = null; + #boundHandleDragLeave: ((e: DragEvent) => void) | null = null; + #boundHandleWindowDragOver: ((e: DragEvent) => void) | null = null; + #boundHandleWindowDrop: ((e: DragEvent) => void) | null = null; + + // ========================================================================== + // Setup + // ========================================================================== + + setDependencies(deps: DragDropDependencies): void { + this.#deps = deps; + } + + bind(): void { + if (!this.#deps) return; + + const viewportHost = this.#deps.getViewportHost(); + const painterHost = this.#deps.getPainterHost(); + + // Create bound handlers + this.#boundHandleDragStart = this.#handleDragStart.bind(this); + this.#boundHandleDragOver = this.#handleDragOver.bind(this); + this.#boundHandleDrop = this.#handleDrop.bind(this); + this.#boundHandleDragEnd = this.#handleDragEnd.bind(this); + this.#boundHandleDragLeave = this.#handleDragLeave.bind(this); + this.#boundHandleWindowDragOver = this.#handleWindowDragOver.bind(this); + this.#boundHandleWindowDrop = this.#handleWindowDrop.bind(this); + + // 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) + viewportHost.addEventListener('dragover', this.#boundHandleDragOver); + viewportHost.addEventListener('drop', this.#boundHandleDrop); + + // Window-level listeners for overlay fallback + window.addEventListener('dragover', this.#boundHandleWindowDragOver, false); + window.addEventListener('drop', this.#boundHandleWindowDrop, false); + } + + unbind(): void { + if (!this.#deps) return; + + const viewportHost = this.#deps.getViewportHost(); + const painterHost = this.#deps.getPainterHost(); + + if (this.#boundHandleDragStart) { + painterHost.removeEventListener('dragstart', this.#boundHandleDragStart); + } + 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.#boundHandleWindowDragOver) { + window.removeEventListener('dragover', this.#boundHandleWindowDragOver, false); + } + if (this.#boundHandleWindowDrop) { + window.removeEventListener('drop', this.#boundHandleWindowDrop, false); + } + + // Clear references + this.#boundHandleDragStart = null; + this.#boundHandleDragOver = null; + this.#boundHandleDrop = null; + this.#boundHandleDragEnd = null; + this.#boundHandleDragLeave = null; + this.#boundHandleWindowDragOver = null; + this.#boundHandleWindowDrop = null; + } + + destroy(): void { + this.unbind(); + this.#deps = null; + } + + // ========================================================================== + // Event Handlers + // ========================================================================== + + /** + * Handle dragstart for internal field annotations. + */ + #handleDragStart(event: DragEvent): void { + const target = event.target as HTMLElement; + + // Only handle draggable field annotations + if (!target?.dataset?.draggable || target.dataset.draggable !== 'true') { + return; + } + + const data = extractFieldAnnotationData(target); + + if (event.dataTransfer) { + const jsonData = JSON.stringify({ + attributes: data.attributes, + sourceField: data, + }); + + // Set in both MIME types for compatibility + event.dataTransfer.setData(INTERNAL_MIME_TYPE, jsonData); + event.dataTransfer.setData(FIELD_ANNOTATION_DATA_TYPE, jsonData); + event.dataTransfer.setData('text/plain', data.displayLabel ?? 'Field Annotation'); + event.dataTransfer.setDragImage(target, 0, 0); + event.dataTransfer.effectAllowed = 'move'; + } + } + + /** + * Handle dragover - update cursor position to show drop location. + */ + #handleDragOver(event: DragEvent): void { + if (!this.#deps) return; + if (!hasFieldAnnotationData(event)) return; + + const activeEditor = this.#deps.getActiveEditor(); + if (!activeEditor?.isEditable) return; + + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + } + + // Update cursor position + const hit = this.#deps.hitTest(event.clientX, event.clientY); + const doc = activeEditor.state?.doc; + if (!hit || !doc) return; + + const pos = Math.min(Math.max(hit.pos, 1), doc.content.size); + const currentSelection = activeEditor.state.selection; + if (currentSelection instanceof TextSelection && currentSelection.from === pos && currentSelection.to === pos) { + return; + } + + try { + const tr = activeEditor.state.tr.setSelection(TextSelection.create(doc, pos)).setMeta('addToHistory', false); + activeEditor.view?.dispatch(tr); + this.#deps.scheduleSelectionUpdate(); + } catch { + // Position may be invalid during layout updates + } + } + + /** + * Handle drop - either move internal annotation or insert external one. + */ + #handleDrop(event: DragEvent): void { + if (!this.#deps) return; + if (!hasFieldAnnotationData(event)) return; + + event.preventDefault(); + event.stopPropagation(); + + 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; + if (dropPos == null) return; + + // Handle internal drag (move existing annotation) + if (isInternalDrag(event)) { + this.#handleInternalDrop(event, dropPos); + return; + } + + // Handle external drag (insert new annotation) + this.#handleExternalDrop(event, dropPos); + } + + /** + * Handle internal drop - move field annotation within document. + */ + #handleInternalDrop(event: DragEvent, targetPos: number): void { + if (!this.#deps) return; + + const activeEditor = this.#deps.getActiveEditor(); + const { state, view } = activeEditor; + if (!state || !view) return; + + const data = extractDragData(event); + if (!data?.fieldId) return; + + // Find source annotation position + const pmStart = data.pmStart; + let sourceStart: number | null = null; + let sourceEnd: number | null = null; + let sourceNode: ReturnType = null; + + if (pmStart != null) { + const nodeAt = state.doc.nodeAt(pmStart); + if (nodeAt?.type?.name === 'fieldAnnotation') { + sourceStart = pmStart; + sourceEnd = pmStart + nodeAt.nodeSize; + sourceNode = nodeAt; + } + } + + // Fallback to fieldId search + if (sourceStart == null || sourceEnd == null || !sourceNode) { + state.doc.descendants((node, pos) => { + if (node.type.name === 'fieldAnnotation' && (node.attrs as { fieldId?: string }).fieldId === data.fieldId) { + sourceStart = pos; + sourceEnd = pos + node.nodeSize; + sourceNode = node; + return false; + } + return true; + }); + } + + if (sourceStart === null || sourceEnd === null || !sourceNode) return; + + // Skip if dropping at same position + if (targetPos >= sourceStart && targetPos <= sourceEnd) return; + + // Move: delete from source, insert at target + const tr = state.tr; + tr.delete(sourceStart, sourceEnd); + const mappedTarget = tr.mapping.map(targetPos); + if (mappedTarget < 0 || mappedTarget > tr.doc.content.size) return; + + tr.insert(mappedTarget, sourceNode); + tr.setMeta('uiEvent', 'drop'); + view.dispatch(tr); + } + + /** + * Handle external drop - insert new field annotation. + */ + #handleExternalDrop(event: DragEvent, pos: number): void { + if (!this.#deps) return; + + const activeEditor = this.#deps.getActiveEditor(); + const fieldAnnotationData = event.dataTransfer?.getData(FIELD_ANNOTATION_DATA_TYPE); + if (!fieldAnnotationData) return; + + let parsedData: FieldAnnotationDragPayload | null = null; + try { + parsedData = JSON.parse(fieldAnnotationData) as FieldAnnotationDragPayload; + } catch { + return; + } + + const { attributes, sourceField } = parsedData ?? {}; + + // Emit event for external handlers + activeEditor.emit?.('fieldAnnotationDropped', { + sourceField, + editor: activeEditor, + coordinates: this.#deps.hitTest(event.clientX, event.clientY), + pos, + }); + + // Insert if attributes are valid + if (attributes && isValidFieldAnnotationAttributes(attributes)) { + activeEditor.commands?.addFieldAnnotation?.(pos, attributes, true); + + // Move caret after inserted node + const posAfter = Math.min(pos + 1, activeEditor.state?.doc?.content.size ?? pos + 1); + const tr = activeEditor.state?.tr.setSelection(TextSelection.create(activeEditor.state.doc, posAfter)); + if (tr) { + activeEditor.view?.dispatch(tr); + } + this.#deps.scheduleSelectionUpdate(); + } + + // Focus editor + const editorDom = activeEditor.view?.dom as HTMLElement | undefined; + if (editorDom) { + editorDom.focus(); + activeEditor.view?.focus(); + } + } + + #handleDragEnd(_event: DragEvent): void { + // Remove visual feedback + this.#deps?.getPainterHost()?.classList.remove('drag-over'); + } + + #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 dragover to allow drops on overlay elements. + */ + #handleWindowDragOver(event: DragEvent): void { + if (!hasFieldAnnotationData(event)) return; + + const viewportHost = this.#deps?.getViewportHost(); + const target = event.target as HTMLElement; + + // Only handle if outside viewport (overlay elements) + if (viewportHost?.contains(target)) return; + + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + } + + // Still update cursor position for overlay drops + this.#handleDragOver(event); + } + + /** + * Window-level drop to catch drops on overlay elements. + */ + #handleWindowDrop(event: DragEvent): void { + if (!hasFieldAnnotationData(event)) return; + + const viewportHost = this.#deps?.getViewportHost(); + const target = event.target as HTMLElement; + + // Only handle if outside viewport (overlay elements) + if (viewportHost?.contains(target)) return; + + this.#handleDrop(event); + } +} diff --git a/packages/super-editor/src/core/presentation-editor/input/FieldAnnotationDragDrop.ts b/packages/super-editor/src/core/presentation-editor/input/FieldAnnotationDragDrop.ts deleted file mode 100644 index 1feeac933..000000000 --- a/packages/super-editor/src/core/presentation-editor/input/FieldAnnotationDragDrop.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { TextSelection } from 'prosemirror-state'; -import { createDragHandler } from '@superdoc/layout-bridge'; -import type { DropEvent, DragOverEvent, PositionHit } from '@superdoc/layout-bridge'; -import type { Editor } from '../../Editor.js'; - -/** - * Attributes for a field annotation node - */ -export interface FieldAnnotationAttributes { - fieldId: string; - fieldType: string; - displayLabel: string; - type: string; - fieldColor?: string; -} - -/** - * Information about the source field being dragged - */ -export interface SourceFieldInfo { - fieldId: string; - fieldType: string; - annotationType: string; -} - -/** - * Payload structure for field annotation drag-and-drop data - */ -export interface FieldAnnotationDragPayload { - /** Attributes to apply to the inserted field annotation */ - attributes?: FieldAnnotationAttributes; - /** Source field information for tracking drop origin */ - sourceField?: SourceFieldInfo; -} - -/** - * Type guard to validate field annotation attributes - * @param attrs - Unknown value to validate - * @returns True if attrs is a valid FieldAnnotationAttributes object - */ -export function isValidFieldAnnotationAttributes(attrs: unknown): attrs is FieldAnnotationAttributes { - if (!attrs || typeof attrs !== 'object') return false; - const a = attrs as Record; - return ( - typeof a.fieldId === 'string' && - typeof a.fieldType === 'string' && - typeof a.displayLabel === 'string' && - typeof a.type === 'string' - ); -} - -/** - * MIME type identifier for field annotation drag-and-drop operations. - */ -export const FIELD_ANNOTATION_DATA_TYPE = 'fieldAnnotation' as const; - -/** - * Dependencies required for internal field annotation drag handling. - */ -type InternalDragDeps = { - /** The DOM container hosting the rendered pages */ - painterHost: HTMLElement; - /** Function to get the current active editor instance */ - getActiveEditor: () => Editor; - /** Function to convert client coordinates to ProseMirror position */ - hitTest: (clientX: number, clientY: number) => PositionHit | null; - /** Function to schedule a selection overlay update */ - scheduleSelectionUpdate: () => void; -}; - -/** - * Sets up drag-and-drop handlers for field annotations within the editor. - * - * Creates internal drag handlers using the layout engine's createDragHandler utility, - * handling both drag-over (cursor positioning) and drop (move/insert) events for - * field annotation nodes. Supports moving existing field annotations and inserting - * new ones from external sources. - * - * @param deps - Dependencies including painter host, editor access, hit testing, and selection updates - * @returns Cleanup function to remove the drag handlers - * - * @remarks - * - Uses layout engine's hit testing to map client coordinates to PM positions - * - Updates cursor position during drag-over to show drop location - * - For existing field annotations (with fieldId), performs a move operation - * - For new annotations (without fieldId), inserts at drop position - * - Handles position mapping after delete to ensure correct insertion point - * - Emits 'fieldAnnotationDropped' event for external handling if attributes are invalid - */ -export function setupInternalFieldAnnotationDragHandlers({ - painterHost, - getActiveEditor, - hitTest, - scheduleSelectionUpdate, -}: InternalDragDeps): () => void { - return createDragHandler(painterHost, { - onDragOver: (event: DragOverEvent) => { - if (!event.hasFieldAnnotation || event.event.clientX === 0) { - return; - } - - const activeEditor = getActiveEditor(); - if (!activeEditor?.isEditable) { - return; - } - - // Use the layout engine's hit testing to get the PM position - const hit = hitTest(event.clientX, event.clientY); - const doc = activeEditor.state?.doc; - if (!hit || !doc) { - return; - } - - // Clamp position to valid range - const pos = Math.min(Math.max(hit.pos, 1), doc.content.size); - - // Skip if cursor hasn't moved - const currentSelection = activeEditor.state.selection; - if (currentSelection instanceof TextSelection && currentSelection.from === pos && currentSelection.to === pos) { - return; - } - - // Update the selection to show caret at drop position - try { - const tr = activeEditor.state.tr.setSelection(TextSelection.create(doc, pos)).setMeta('addToHistory', false); - activeEditor.view?.dispatch(tr); - scheduleSelectionUpdate(); - } catch { - // Position may be invalid during layout updates - ignore - } - }, - onDrop: (event: DropEvent) => { - // Prevent other drop handlers from double-processing - event.event.preventDefault(); - event.event.stopPropagation(); - - if (event.pmPosition === null) { - return; - } - - const activeEditor = getActiveEditor(); - const { state, view } = activeEditor; - if (!state || !view) { - return; - } - - // If the source has fieldId (meaning it was dragged from an existing position), - // we MOVE the field annotation (delete from old, insert at new) - const fieldId = event.data.fieldId; - if (fieldId) { - const targetPos = event.pmPosition; - - // Prefer the original PM start position when available to avoid ambiguity - const pmStart = event.data.pmStart; - let sourceStart: number | null = null; - let sourceEnd: number | null = null; - let sourceNode: typeof state.doc extends { nodeAt: (p: number) => infer N } ? N : never = null; - - if (pmStart != null) { - const nodeAt = state.doc.nodeAt(pmStart); - if (nodeAt?.type?.name === 'fieldAnnotation') { - sourceStart = pmStart; - sourceEnd = pmStart + nodeAt.nodeSize; - sourceNode = nodeAt; - } - } - - // Fallback to fieldId search if PM position is missing or stale - if (sourceStart == null || sourceEnd == null || !sourceNode) { - state.doc.descendants((node, pos) => { - if (node.type.name === 'fieldAnnotation' && (node.attrs as { fieldId?: string }).fieldId === fieldId) { - sourceStart = pos; - sourceEnd = pos + node.nodeSize; - sourceNode = node; - return false; // Stop traversal - } - return true; - }); - } - - if (sourceStart === null || sourceEnd === null || !sourceNode) { - return; - } - - // Skip if dropping at the same position (or immediately adjacent) - if (targetPos >= sourceStart && targetPos <= sourceEnd) { - return; - } - - // Create a transaction to move the field annotation - const tr = state.tr; - - // First delete the source annotation - tr.delete(sourceStart, sourceEnd); - - // Use ProseMirror's mapping to get the correct target position after the delete - // This properly handles document structure changes and edge cases - const mappedTarget = tr.mapping.map(targetPos); - - // Validate the mapped position is within document bounds - if (mappedTarget < 0 || mappedTarget > tr.doc.content.size) { - return; - } - - // Then insert the same node at the mapped target position - tr.insert(mappedTarget, sourceNode); - tr.setMeta('uiEvent', 'drop'); - - view.dispatch(tr); - return; - } - - // No source position - this is a new drop from outside, insert directly if attributes look valid - const attrs = event.data.attributes; - if (attrs && isValidFieldAnnotationAttributes(attrs)) { - const inserted = activeEditor.commands?.addFieldAnnotation?.(event.pmPosition, attrs, true); - if (inserted) { - scheduleSelectionUpdate(); - } - return; - } - - // Fallback: emit event for any external handlers - activeEditor.emit('fieldAnnotationDropped', { - sourceField: event.data, - editor: activeEditor, - coordinates: { pos: event.pmPosition }, - }); - }, - }); -} - -/** - * Dependencies required for external field annotation drag handling. - */ -type ExternalDragDeps = { - /** Function to get the current active editor instance */ - getActiveEditor: () => Editor; - /** Function to convert client coordinates to ProseMirror position */ - hitTest: (clientX: number, clientY: number) => PositionHit | null; - /** Function to schedule a selection overlay update */ - scheduleSelectionUpdate: () => void; -}; - -/** - * Creates a drag-over handler for field annotations from external sources. - * - * Handles dragover events when field annotations are dragged from outside the editor - * (e.g., from a field palette UI). Updates the editor cursor position to show where - * the field will be inserted if dropped. - * - * @param deps - Dependencies including editor access, hit testing, and selection updates - * @returns Drag-over event handler function - * - * @remarks - * - Only processes events when editor is editable - * - Checks for FIELD_ANNOTATION_DATA_TYPE in dataTransfer types - * - Sets dropEffect to 'copy' to indicate insertion - * - Uses hit testing to map coordinates to PM position - * - Updates selection to show cursor at drop position - * - Skips update if cursor is already at the target position - */ -export function createExternalFieldAnnotationDragOverHandler({ - getActiveEditor, - hitTest, - scheduleSelectionUpdate, -}: ExternalDragDeps): (event: DragEvent) => void { - return (event: DragEvent) => { - const activeEditor = getActiveEditor(); - if (!activeEditor?.isEditable) { - return; - } - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'copy'; - } - - const dt = event.dataTransfer; - const hasFieldAnnotation = - dt?.types?.includes(FIELD_ANNOTATION_DATA_TYPE) || Boolean(dt?.getData?.(FIELD_ANNOTATION_DATA_TYPE)); - if (!hasFieldAnnotation) { - return; - } - - const hit = hitTest(event.clientX, event.clientY); - const doc = activeEditor.state?.doc; - if (!hit || !doc) { - return; - } - - const pos = Math.min(Math.max(hit.pos, 1), doc.content.size); - const currentSelection = activeEditor.state.selection; - const isSameCursor = - currentSelection instanceof TextSelection && currentSelection.from === pos && currentSelection.to === pos; - - if (isSameCursor) { - return; - } - - try { - const tr = activeEditor.state.tr.setSelection(TextSelection.create(doc, pos)).setMeta('addToHistory', false); - activeEditor.view?.dispatch(tr); - scheduleSelectionUpdate(); - } catch (error) { - // Position may be invalid during layout updates - expected during re-layout - if (process.env.NODE_ENV === 'development') { - console.debug('[PresentationEditor] Drag position update skipped:', error); - } - } - }; -} - -/** - * Creates a drop handler for field annotations from external sources. - * - * Handles drop events when field annotations are dragged from outside the editor. - * Parses the dataTransfer payload, validates attributes, inserts the field annotation - * at the drop position, and emits events for external handling. - * - * @param deps - Dependencies including editor access, hit testing, and selection updates - * @returns Drop event handler function - * - * @remarks - * - Only processes events when editor is editable - * - Skips internal layout-engine drags (application/x-field-annotation MIME type) - * - Parses JSON payload from FIELD_ANNOTATION_DATA_TYPE dataTransfer - * - Uses hit testing to determine drop position (falls back to current selection) - * - Validates attributes before insertion - * - Emits 'fieldAnnotationDropped' event with source field info and position - * - Moves caret after inserted node to enable sequential drops - * - Focuses editor and updates selection after insertion - */ -export function createExternalFieldAnnotationDropHandler({ - getActiveEditor, - hitTest, - scheduleSelectionUpdate, -}: ExternalDragDeps): (event: DragEvent) => void { - return (event: DragEvent) => { - const activeEditor = getActiveEditor(); - if (!activeEditor?.isEditable) { - return; - } - - // Internal layout-engine drags use a custom MIME type and are handled by DragHandler. - if (event.dataTransfer?.types?.includes('application/x-field-annotation')) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const fieldAnnotationData = event.dataTransfer?.getData(FIELD_ANNOTATION_DATA_TYPE); - if (!fieldAnnotationData) { - return; - } - - const hit = hitTest(event.clientX, event.clientY); - // If layout hit testing fails (e.g., during a reflow), fall back to the current selection. - const selection = activeEditor.state?.selection; - const fallbackPos = selection?.from ?? activeEditor.state?.doc?.content.size ?? null; - const pos = hit?.pos ?? fallbackPos; - if (pos == null) { - return; - } - - let parsedData: FieldAnnotationDragPayload | null = null; - try { - parsedData = JSON.parse(fieldAnnotationData) as FieldAnnotationDragPayload; - } catch { - return; - } - - const { attributes, sourceField } = parsedData ?? {}; - - activeEditor.emit?.('fieldAnnotationDropped', { - sourceField, - editor: activeEditor, - coordinates: hit, - pos, - }); - - // Validate attributes before attempting insertion - if (attributes && isValidFieldAnnotationAttributes(attributes)) { - activeEditor.commands?.addFieldAnnotation?.(pos, attributes, true); - - // Move the caret to just after the inserted node so subsequent drops append instead of replacing. - const posAfter = Math.min(pos + 1, activeEditor.state?.doc?.content.size ?? pos + 1); - const tr = activeEditor.state?.tr.setSelection(TextSelection.create(activeEditor.state.doc, posAfter)); - if (tr) { - activeEditor.view?.dispatch(tr); - } - - scheduleSelectionUpdate(); - } - - const editorDom = activeEditor.view?.dom as HTMLElement | undefined; - if (editorDom) { - editorDom.focus(); - activeEditor.view?.focus(); - } - }; -} diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/PointerEventManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts similarity index 92% rename from packages/super-editor/src/core/presentation-editor/pointer-events/PointerEventManager.ts rename to packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index 407dee977..5caf15519 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/PointerEventManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1,13 +1,15 @@ /** - * PointerEventManager - Handles pointer/input events for PresentationEditor. + * EditorInputManager - Handles pointer/input events for PresentationEditor. * - * This manager encapsulates all pointer event handling including: + * This manager encapsulates all pointer and focus event handling including: * - Pointer down/move/up handlers * - Drag selection state machine * - Cell selection for tables * - Multi-click detection (double/triple click) * - Link click handling * - Image selection + * - Focus management + * - Header/footer hover interactions */ import { Selection, TextSelection, NodeSelection } from 'prosemirror-state'; @@ -33,10 +35,6 @@ import { getTablePosFromHit as getTablePosFromHitFromHelper, hitTestTable as hitTestTableFromHelper, } from '../tables/TableSelectionUtilities.js'; -import { - createExternalFieldAnnotationDragOverHandler, - createExternalFieldAnnotationDropHandler, -} from '../input/FieldAnnotationDragDrop.js'; import { debugLog } from '../selection/SelectionDebug.js'; // ============================================================================= @@ -62,7 +60,7 @@ export type LayoutState = { /** * Dependencies injected from PresentationEditor. */ -export type PointerEventDependencies = { +export type EditorInputDependencies = { /** Get the active editor (body or header/footer) */ getActiveEditor: () => Editor; /** Get the main body editor */ @@ -95,7 +93,7 @@ export type PointerEventDependencies = { * Callbacks for events that the manager emits. * All callbacks are optional to allow incremental setup. */ -export type PointerEventCallbacks = { +export type EditorInputCallbacks = { /** Schedule selection update */ scheduleSelectionUpdate?: () => void; /** Schedule rerender */ @@ -155,13 +153,13 @@ export type PointerEventCallbacks = { }; // ============================================================================= -// PointerEventManager Class +// EditorInputManager Class // ============================================================================= -export class PointerEventManager { +export class EditorInputManager { // Dependencies - #deps: PointerEventDependencies | null = null; - #callbacks: PointerEventCallbacks = {}; + #deps: EditorInputDependencies | null = null; + #callbacks: EditorInputCallbacks = {}; // Drag selection state #isDragging = false; @@ -206,8 +204,6 @@ export class PointerEventManager { #boundHandlePointerLeave: (() => void) | null = null; #boundHandleDoubleClick: ((e: MouseEvent) => void) | null = null; #boundHandleKeyDown: ((e: KeyboardEvent) => void) | null = null; - #boundHandleDragOver: ((e: DragEvent) => void) | null = null; - #boundHandleDrop: ((e: DragEvent) => void) | null = null; #boundHandleFocusIn: ((e: FocusEvent) => void) | null = null; // ========================================================================== @@ -225,14 +221,14 @@ export class PointerEventManager { /** * Set dependencies from PresentationEditor. */ - setDependencies(deps: PointerEventDependencies): void { + setDependencies(deps: EditorInputDependencies): void { this.#deps = deps; } /** * Set callbacks for events. */ - setCallbacks(callbacks: PointerEventCallbacks): void { + setCallbacks(callbacks: EditorInputCallbacks): void { this.#callbacks = callbacks; } @@ -253,8 +249,6 @@ export class PointerEventManager { this.#boundHandlePointerLeave = this.#handlePointerLeave.bind(this); this.#boundHandleDoubleClick = this.#handleDoubleClick.bind(this); this.#boundHandleKeyDown = this.#handleKeyDown.bind(this); - this.#boundHandleDragOver = this.#createDragOverHandler(); - this.#boundHandleDrop = this.#createDropHandler(); this.#boundHandleFocusIn = this.#handleFocusIn.bind(this); // Attach pointer event listeners @@ -263,7 +257,6 @@ export class PointerEventManager { viewportHost.addEventListener('pointerup', this.#boundHandlePointerUp); viewportHost.addEventListener('pointerleave', this.#boundHandlePointerLeave); viewportHost.addEventListener('dblclick', this.#boundHandleDoubleClick); - viewportHost.addEventListener('dragover', this.#boundHandleDragOver); // Keyboard events on container const container = viewportHost.closest('.presentation-editor') as HTMLElement | null; @@ -299,9 +292,6 @@ export class PointerEventManager { if (this.#boundHandleDoubleClick) { viewportHost.removeEventListener('dblclick', this.#boundHandleDoubleClick); } - if (this.#boundHandleDragOver) { - viewportHost.removeEventListener('dragover', this.#boundHandleDragOver); - } if (this.#boundHandleKeyDown) { const container = viewportHost.closest('.presentation-editor') as HTMLElement | null; if (container) { @@ -319,8 +309,6 @@ export class PointerEventManager { this.#boundHandlePointerLeave = null; this.#boundHandleDoubleClick = null; this.#boundHandleKeyDown = null; - this.#boundHandleDragOver = null; - this.#boundHandleDrop = null; this.#boundHandleFocusIn = null; } @@ -374,6 +362,21 @@ export class PointerEventManager { return this.#lastSelectedImageBlockId; } + /** Drag anchor page index */ + get dragAnchorPageIndex(): number | null { + return this.#dragAnchorPageIndex; + } + + /** Get the page index from the last raw hit during drag */ + get dragLastHitPageIndex(): number | null { + return this.#dragLastRawHit?.pageIndex ?? null; + } + + /** Get the last raw hit during drag (for finalization) */ + get dragLastRawHit(): PositionHit | null { + return this.#dragLastRawHit; + } + // ========================================================================== // Public Methods // ========================================================================== @@ -417,7 +420,7 @@ export class PointerEventManager { { clickCount: this.#clickCount, lastClickTime: this.#lastClickTime, - lastClickPosition: this.#lastClickPosition, + lastClickPosition: this.#lastClickPosition ?? { x: 0, y: 0 }, }, { timeThresholdMs: MULTI_CLICK_TIME_THRESHOLD_MS, @@ -481,60 +484,6 @@ export class PointerEventManager { return this.#callbacks.hitTestTable?.(x, y) ?? null; } - #createDragOverHandler(): (e: DragEvent) => void { - return createExternalFieldAnnotationDragOverHandler({ - getActiveEditor: () => this.#deps?.getActiveEditor() as Editor, - hitTest: (clientX: number, clientY: number) => { - const layoutState = this.#deps?.getLayoutState(); - const viewportHost = this.#deps?.getViewportHost(); - const pageGeometryHelper = this.#deps?.getPageGeometryHelper(); - if (!layoutState?.layout || !viewportHost) return null; - - const normalized = this.#callbacks.normalizeClientPoint?.(clientX, clientY); - if (!normalized) return null; - - return clickToPosition( - layoutState.layout, - layoutState.blocks, - layoutState.measures, - { x: normalized.x, y: normalized.y }, - viewportHost, - clientX, - clientY, - pageGeometryHelper ?? undefined, - ); - }, - scheduleSelectionUpdate: () => this.#callbacks.scheduleSelectionUpdate?.(), - }); - } - - #createDropHandler(): (e: DragEvent) => void { - return createExternalFieldAnnotationDropHandler({ - getActiveEditor: () => this.#deps?.getActiveEditor() as Editor, - hitTest: (clientX: number, clientY: number) => { - const layoutState = this.#deps?.getLayoutState(); - const viewportHost = this.#deps?.getViewportHost(); - const pageGeometryHelper = this.#deps?.getPageGeometryHelper(); - if (!layoutState?.layout || !viewportHost) return null; - - const normalized = this.#callbacks.normalizeClientPoint?.(clientX, clientY); - if (!normalized) return null; - - return clickToPosition( - layoutState.layout, - layoutState.blocks, - layoutState.measures, - { x: normalized.x, y: normalized.y }, - viewportHost, - clientX, - clientY, - pageGeometryHelper ?? undefined, - ); - }, - scheduleSelectionUpdate: () => this.#callbacks.scheduleSelectionUpdate?.(), - }); - } - // ========================================================================== // Event Handlers // ========================================================================== @@ -649,7 +598,7 @@ export class PointerEventManager { const fragmentHit = getFragmentAtPosition(layoutState.layout, layoutState.blocks, layoutState.measures, rawHit.pos); // Handle inline image click - const targetImg = (event.target as HTMLElement | null)?.closest?.('img'); + const targetImg = (event.target as HTMLElement | null)?.closest?.('img') as HTMLImageElement | null; if (this.#handleInlineImageClick(event, targetImg, rawHit, doc, epochMapper)) return; // Handle atomic fragment (image/drawing) click @@ -1031,7 +980,7 @@ export class PointerEventManager { this.#lastSelectedImageBlockId = newSelectionId; } catch (error) { if (process.env.NODE_ENV === 'development') { - console.warn(`[PointerEventManager] Failed to create NodeSelection for inline image:`, error); + console.warn(`[EditorInputManager] Failed to create NodeSelection for inline image:`, error); } } @@ -1073,7 +1022,7 @@ export class PointerEventManager { } } catch (error) { if (process.env.NODE_ENV === 'development') { - console.warn('[PointerEventManager] Failed to create NodeSelection for atomic fragment:', error); + console.warn('[EditorInputManager] Failed to create NodeSelection for atomic fragment:', error); } } diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/index.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/index.ts index e3d60c8b4..3a8bceb24 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/index.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/index.ts @@ -1,6 +1,6 @@ /** - * Pointer event handling module for PresentationEditor. + * Editor input handling module for PresentationEditor. */ -export { PointerEventManager } from './PointerEventManager.js'; -export type { LayoutState, PointerEventDependencies, PointerEventCallbacks } from './PointerEventManager.js'; +export { EditorInputManager } from './EditorInputManager.js'; +export type { LayoutState, EditorInputDependencies, EditorInputCallbacks } from './EditorInputManager.js'; From 5e5541bae6d9fef8c242cce2de3bd160c2c56c32 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 16 Jan 2026 14:20:47 -0800 Subject: [PATCH 2/3] chore: hyperlink interaction fix after refactor --- .../pointer-events/EditorInputManager.ts | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index 5caf15519..fb65106e2 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -203,6 +203,7 @@ export class EditorInputManager { #boundHandlePointerUp: ((e: PointerEvent) => void) | null = null; #boundHandlePointerLeave: (() => void) | null = null; #boundHandleDoubleClick: ((e: MouseEvent) => void) | null = null; + #boundHandleClick: ((e: MouseEvent) => void) | null = null; #boundHandleKeyDown: ((e: KeyboardEvent) => void) | null = null; #boundHandleFocusIn: ((e: FocusEvent) => void) | null = null; @@ -248,6 +249,7 @@ export class EditorInputManager { this.#boundHandlePointerUp = this.#handlePointerUp.bind(this); this.#boundHandlePointerLeave = this.#handlePointerLeave.bind(this); this.#boundHandleDoubleClick = this.#handleDoubleClick.bind(this); + this.#boundHandleClick = this.#handleClick.bind(this); this.#boundHandleKeyDown = this.#handleKeyDown.bind(this); this.#boundHandleFocusIn = this.#handleFocusIn.bind(this); @@ -257,6 +259,7 @@ export class EditorInputManager { viewportHost.addEventListener('pointerup', this.#boundHandlePointerUp); viewportHost.addEventListener('pointerleave', this.#boundHandlePointerLeave); viewportHost.addEventListener('dblclick', this.#boundHandleDoubleClick); + viewportHost.addEventListener('click', this.#boundHandleClick); // Keyboard events on container const container = viewportHost.closest('.presentation-editor') as HTMLElement | null; @@ -292,6 +295,9 @@ export class EditorInputManager { if (this.#boundHandleDoubleClick) { viewportHost.removeEventListener('dblclick', this.#boundHandleDoubleClick); } + if (this.#boundHandleClick) { + viewportHost.removeEventListener('click', this.#boundHandleClick); + } if (this.#boundHandleKeyDown) { const container = viewportHost.closest('.presentation-editor') as HTMLElement | null; if (container) { @@ -308,6 +314,7 @@ export class EditorInputManager { this.#boundHandlePointerUp = null; this.#boundHandlePointerLeave = null; this.#boundHandleDoubleClick = null; + this.#boundHandleClick = null; this.#boundHandleKeyDown = null; this.#boundHandleFocusIn = null; } @@ -488,6 +495,33 @@ export class EditorInputManager { // Event Handlers // ========================================================================== + /** + * Handle click events - specifically for link navigation prevention. + * + * Link handling is split between pointerdown and click: + * - pointerdown: dispatches superdoc-link-click event (for popover/UI response) + * - click: prevents default navigation (preventDefault only works on click, not pointerdown) + * + * This also handles keyboard activation (Enter/Space) which triggers click but not pointerdown. + */ + #handleClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + + const linkEl = target?.closest?.('a.superdoc-link') as HTMLAnchorElement | null; + if (linkEl) { + // Prevent browser navigation - this is the only place it can be reliably prevented + event.preventDefault(); + + // For keyboard activation (Enter/Space), dispatch the custom event + // Mouse clicks already dispatched the event on pointerdown + // We detect keyboard by checking if this wasn't preceded by a recent pointerdown + if (!(event as PointerEvent).pointerId && event.detail === 0) { + // detail === 0 indicates keyboard activation, not mouse click + this.#handleLinkClick(event, linkEl); + } + } + } + #handlePointerDown(event: PointerEvent): void { if (!this.#deps) return; @@ -504,7 +538,8 @@ export class EditorInputManager { // Skip ruler handle clicks if (target?.closest?.('.superdoc-ruler-handle') != null) return; - // Handle link clicks + // Handle link clicks - dispatch custom event on pointerdown for immediate UI response + // Navigation prevention happens in #handleClick (on 'click' event) const linkEl = target?.closest?.('a.superdoc-link') as HTMLAnchorElement | null; if (linkEl) { this.#handleLinkClick(event, linkEl); @@ -840,7 +875,7 @@ export class EditorInputManager { // Handler Helpers // ========================================================================== - #handleLinkClick(event: PointerEvent, linkEl: HTMLAnchorElement): void { + #handleLinkClick(event: MouseEvent, linkEl: HTMLAnchorElement): void { const href = linkEl.getAttribute('href') ?? ''; const isAnchorLink = href.startsWith('#') && href.length > 1; const isTocLink = linkEl.closest('.superdoc-toc-entry') !== null; From 03b8bf868eb396e49f5b0384704a8cdbe54bd605 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 16 Jan 2026 14:29:08 -0800 Subject: [PATCH 3/3] chore: add test fix --- packages/layout-engine/painters/dom/src/link-click.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/layout-engine/painters/dom/src/link-click.test.ts b/packages/layout-engine/painters/dom/src/link-click.test.ts index b192ee025..d826043b0 100644 --- a/packages/layout-engine/painters/dom/src/link-click.test.ts +++ b/packages/layout-engine/painters/dom/src/link-click.test.ts @@ -109,6 +109,7 @@ describe('DomPainter - Link Rendering', () => { target: '_blank', rel: 'noopener noreferrer', tooltip: 'Example tooltip', + version: 2, }, }, ],