From 2ea9fbd9878a9918720ec90b93c3ff7072f4a34f Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Mon, 20 Apr 2026 22:24:30 -0700 Subject: [PATCH 1/4] feat: implement internal node move functionality and structured content resolution - Added internal node move logic to handle drag-and-drop operations for nodes within the editor. - Introduced structured content resolution helpers to find structured content blocks and inlines by position and ID. - Enhanced EditorInputManager to utilize new structured content resolution methods for better drag-and-drop handling. - Created comprehensive tests for drag-and-drop behavior involving existing images and structured content. - Implemented helper functions for simulating drag-and-drop events in tests. --- .../presentation-editor/PresentationEditor.ts | 61 +++--- .../dom/EditorStyleInjector.test.ts | 19 ++ .../dom/EditorStyleInjector.ts | 36 +++ .../dom/ImageInteractionLayer.test.ts | 85 +++++++ .../dom/ImageInteractionLayer.ts | 103 +++++++++ .../dom/PresentationPostPaintPipeline.test.ts | 73 +++++- .../dom/PresentationPostPaintPipeline.ts | 16 ++ .../StructuredContentInteractionLayer.test.ts | 73 ++++++ .../dom/StructuredContentInteractionLayer.ts | 64 ++++++ .../input/DragDropManager.ts | 170 +++++++++++--- .../input/internal-drag-payloads.test.ts | 108 +++++++++ .../input/internal-drag-payloads.ts | 147 +++++++++++++ .../input/internal-node-move.test.ts | 159 ++++++++++++++ .../input/internal-node-move.ts | 117 ++++++++++ .../input/structured-content-resolution.ts | 111 ++++++++++ .../pointer-events/EditorInputManager.ts | 111 ++-------- .../tests/DragDropManager.test.ts | 207 +++++++++++++++++- .../PresentationEditor.draggableFocus.test.ts | 8 + .../structured-content-resolution.test.ts | 150 +++++++++++++ tests/behavior/helpers/drag-drop.ts | 108 +++++++++ .../existing-rendered-image-drag-drop.spec.ts | 78 +++++++ .../behavior/tests/sdt/sdt-drag-drop.spec.ts | 101 +++++++++ 22 files changed, 1953 insertions(+), 152 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/dom/StructuredContentInteractionLayer.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/dom/StructuredContentInteractionLayer.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/input/structured-content-resolution.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/tests/structured-content-resolution.test.ts create mode 100644 tests/behavior/helpers/drag-drop.ts create mode 100644 tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts create mode 100644 tests/behavior/tests/sdt/sdt-drag-drop.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 64498efb5d..0a0f2fcc89 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -14,6 +14,10 @@ import { getFirstTextPosition as getFirstTextPositionFromHelper, registerPointerClick as registerPointerClickFromHelper, } from './input/ClickSelectionUtilities.js'; +import { + findStructuredContentBlockAtPos, + findStructuredContentInlineAtPos, +} from './input/structured-content-resolution.js'; import type { EditorState, Transaction } from 'prosemirror-state'; import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; @@ -128,6 +132,7 @@ import { DOM_CLASS_NAMES, buildSdtBlockSelector } from '@superdoc/dom-contract'; import { ensureEditorNativeSelectionStyles, ensureEditorFieldAnnotationInteractionStyles, + ensureEditorMovableObjectInteractionStyles, } from './dom/EditorStyleInjector.js'; import type { ResolveRangeOutput, DocumentApi, NavigableAddress, BlockNavigationAddress } from '@superdoc/document-api'; @@ -494,6 +499,7 @@ export class PresentationEditor extends EventEmitter { // Inject editor-owned styles (idempotent, once per document) ensureEditorNativeSelectionStyles(doc); ensureEditorFieldAnnotationInteractionStyles(doc); + ensureEditorMovableObjectInteractionStyles(doc); // Add event listeners for structured content hover coordination this.#painterHost.addEventListener('mouseover', this.#handleStructuredContentBlockMouseEnter); @@ -4688,6 +4694,7 @@ export class PresentationEditor extends EventEmitter { } let node: ProseMirrorNode | null = null; + let pos: number | null = null; let id: string | null = null; if (selection instanceof NodeSelection) { @@ -4696,24 +4703,27 @@ export class PresentationEditor extends EventEmitter { return; } node = selection.node; + pos = selection.from; } else { - const $pos = (selection as Selection & { $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode } }) - .$from; - if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { + const editorDoc = this.#editor?.view?.state?.doc; + if (!editorDoc) { this.#clearSelectedStructuredContentBlockClass(); return; } - for (let depth = $pos.depth; depth > 0; depth--) { - const candidate = $pos.node(depth); - if (candidate.type?.name === 'structuredContentBlock') { - node = candidate; - break; - } - } - if (!node) { + + const resolved = findStructuredContentBlockAtPos(editorDoc, selection.from); + if (!resolved) { this.#clearSelectedStructuredContentBlockClass(); return; } + + node = resolved.node; + pos = resolved.pos; + } + + if (pos == null) { + this.#clearSelectedStructuredContentBlockClass(); + return; } if (!this.#painterHost) { @@ -4730,7 +4740,7 @@ export class PresentationEditor extends EventEmitter { } if (elements.length === 0) { - const elementAtPos = this.getElementAtPos(selection.from, { fallbackToCoords: true }); + const elementAtPos = this.getElementAtPos(pos, { fallbackToCoords: true }); const container = elementAtPos?.closest?.(`.${DOM_CLASS_NAMES.BLOCK_SDT}`) as HTMLElement | null; if (container) { elements = [container]; @@ -4903,31 +4913,20 @@ export class PresentationEditor extends EventEmitter { node = selection.node; pos = selection.from; } else { - const $pos = ( - selection as Selection & { - $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode; before?: (depth: number) => number }; - } - ).$from; - if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { + const editorDoc = this.#editor?.view?.state?.doc; + if (!editorDoc) { this.#clearSelectedStructuredContentInlineClass(); return; } - for (let depth = $pos.depth; depth > 0; depth--) { - const candidate = $pos.node(depth); - if (candidate.type?.name === 'structuredContent') { - if (typeof $pos.before !== 'function') { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - node = candidate; - pos = $pos.before(depth); - break; - } - } - if (!node || pos == null) { + + const resolved = findStructuredContentInlineAtPos(editorDoc, selection.from); + if (!resolved) { this.#clearSelectedStructuredContentInlineClass(); return; } + + node = resolved.node; + pos = resolved.pos; } if (!this.#painterHost) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts index 48826151b1..fd37d335cb 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ensureEditorNativeSelectionStyles, ensureEditorFieldAnnotationInteractionStyles, + ensureEditorMovableObjectInteractionStyles, _resetEditorStyleFlags, } from './EditorStyleInjector.js'; @@ -9,6 +10,7 @@ afterEach(() => { // Clean up injected styles and reset flags between tests document.querySelectorAll('[data-superdoc-editor-native-selection-styles]').forEach((el) => el.remove()); document.querySelectorAll('[data-superdoc-editor-field-annotation-interaction-styles]').forEach((el) => el.remove()); + document.querySelectorAll('[data-superdoc-editor-movable-object-interaction-styles]').forEach((el) => el.remove()); _resetEditorStyleFlags(); }); @@ -72,3 +74,20 @@ describe('ensureEditorFieldAnnotationInteractionStyles', () => { expect(css).toContain('.superdoc-drop-indicator'); }); }); + +describe('ensureEditorMovableObjectInteractionStyles', () => { + it('injects styles into document head', () => { + ensureEditorMovableObjectInteractionStyles(document); + const styleEl = document.querySelector('[data-superdoc-editor-movable-object-interaction-styles="true"]'); + expect(styleEl).not.toBeNull(); + expect(styleEl?.tagName).toBe('STYLE'); + }); + + it('CSS contains grab cursor rules for draggable object sources', () => { + ensureEditorMovableObjectInteractionStyles(document); + const css = document.querySelector('[data-superdoc-editor-movable-object-interaction-styles]')?.textContent ?? ''; + expect(css).toContain('[data-drag-source-kind]'); + expect(css).toContain('cursor: grab'); + expect(css).toContain('user-select: none'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts index 05c07bcb33..ff0094c004 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts @@ -113,6 +113,41 @@ export function ensureEditorFieldAnnotationInteractionStyles(doc: Document | nul fieldAnnotationInteractionStylesInjected = true; } +// --------------------------------------------------------------------------- +// Movable Object Interaction Styles +// --------------------------------------------------------------------------- + +const MOVABLE_OBJECT_INTERACTION_STYLES = ` +/* Editing affordance: allow grab cursors for draggable SDT labels and images */ +.superdoc-layout [data-drag-source-kind] { + cursor: grab; +} + +.superdoc-layout [data-drag-source-kind]:active { + cursor: grabbing; +} + +/* Keep the active drag source from selecting text while dragging */ +.superdoc-layout .superdoc-structured-content__label, +.superdoc-layout .superdoc-structured-content-inline__label, +.superdoc-layout .superdoc-image-fragment[data-drag-source-kind="existingImage"], +.superdoc-layout .superdoc-inline-image-clip-wrapper[data-drag-source-kind="existingImage"], +.superdoc-layout .superdoc-inline-image[data-drag-source-kind="existingImage"] { + user-select: none; +} +`; + +let movableObjectInteractionStylesInjected = false; + +export function ensureEditorMovableObjectInteractionStyles(doc: Document | null | undefined): void { + if (movableObjectInteractionStylesInjected || !doc) return; + const styleEl = doc.createElement('style'); + styleEl.setAttribute('data-superdoc-editor-movable-object-interaction-styles', 'true'); + styleEl.textContent = MOVABLE_OBJECT_INTERACTION_STYLES; + doc.head?.appendChild(styleEl); + movableObjectInteractionStylesInjected = true; +} + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- @@ -121,4 +156,5 @@ export function ensureEditorFieldAnnotationInteractionStyles(doc: Document | nul export function _resetEditorStyleFlags(): void { nativeSelectionStylesInjected = false; fieldAnnotationInteractionStylesInjected = false; + movableObjectInteractionStylesInjected = false; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts new file mode 100644 index 0000000000..d0f7e2626d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { ImageInteractionLayer } from './ImageInteractionLayer.js'; + +describe('ImageInteractionLayer', () => { + let container: HTMLElement; + let layer: ImageInteractionLayer; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + layer = new ImageInteractionLayer(); + layer.setContainer(container); + }); + + afterEach(() => { + container.remove(); + }); + + it('marks rendered image roots as draggable move sources', () => { + container.innerHTML = ` +
+ Block image +
+ + Inline image + + Loose inline image + `; + + layer.apply(5); + + const block = container.querySelector(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`) as HTMLElement; + const wrapper = container.querySelector(`.${DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER}`) as HTMLElement; + const loose = container.querySelector('img.superdoc-inline-image[data-pm-start="30"]') as HTMLElement; + + expect(block.draggable).toBe(true); + expect(block.dataset.dragSourceKind).toBe('existingImage'); + expect(block.dataset.imageKind).toBe('block'); + expect(block.dataset.displayLabel).toBe('Block image'); + expect(block.getAttribute('data-block-id')).toBe('image-block'); + + expect(wrapper.draggable).toBe(true); + expect(wrapper.dataset.dragSourceKind).toBe('existingImage'); + expect(wrapper.dataset.imageKind).toBe('inline'); + expect(wrapper.dataset.displayLabel).toBe('Inline image'); + + expect(loose.draggable).toBe(true); + expect(loose.dataset.imageKind).toBe('inline'); + expect(loose.dataset.displayLabel).toBe('Loose inline image'); + }); + + it('skips elements without PM position metadata', () => { + container.innerHTML = ` +
+ Missing position +
+ `; + + layer.apply(2); + + const block = container.querySelector(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`) as HTMLElement; + expect(block.hasAttribute('draggable')).toBe(false); + expect(block.dataset.dragSourceKind).toBeUndefined(); + }); + + it('clear removes image drag affordances', () => { + container.innerHTML = ` +
+ Block image +
+ `; + + layer.apply(1); + const block = container.querySelector(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`) as HTMLElement; + expect(block.draggable).toBe(true); + + layer.clear(); + + expect(block.hasAttribute('draggable')).toBe(false); + expect(block.dataset.dragSourceKind).toBeUndefined(); + expect(block.dataset.imageKind).toBeUndefined(); + expect(block.dataset.displayLabel).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts new file mode 100644 index 0000000000..702ac40029 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts @@ -0,0 +1,103 @@ +import { DATASET_KEYS, DOM_CLASS_NAMES } from '@superdoc/dom-contract'; + +const INTERACTION_EPOCH_KEY = 'imageInteractionEpoch'; + +function parsePmNumber(value: string | undefined): string | null { + return value && value.trim().length > 0 ? value : null; +} + +function collectImageRoots(container: HTMLElement): HTMLElement[] { + const roots: HTMLElement[] = []; + const seen = new Set(); + + const add = (element: HTMLElement | null | undefined) => { + if (!element || seen.has(element)) return; + seen.add(element); + roots.push(element); + }; + + for (const fragment of Array.from(container.querySelectorAll(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`))) { + if (fragment.querySelector?.(`[data-image-metadata]`) == null) continue; + add(fragment); + } + + for (const wrapper of Array.from( + container.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER}`), + )) { + if (wrapper.querySelector?.(`[data-image-metadata]`) == null) continue; + add(wrapper); + } + + for (const inlineImage of Array.from(container.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_IMAGE}`))) { + if ( + inlineImage.hasAttribute('data-image-metadata') && + inlineImage.closest(`.${DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER}`) == null + ) { + add(inlineImage); + } + } + + return roots; +} + +function resolveImageLabel(root: HTMLElement): string { + const directLabel = root.dataset[DATASET_KEYS.DISPLAY_LABEL]; + if (directLabel) return directLabel; + + const img = root.tagName === 'IMG' ? root : root.querySelector('img'); + const alt = img?.getAttribute('alt')?.trim(); + if (alt) return alt; + + const title = img?.getAttribute('title')?.trim(); + if (title) return title; + + const blockId = root.getAttribute('data-block-id') ?? root.getAttribute('data-sd-block-id'); + return blockId ?? 'Image'; +} + +function resolveImageKind(root: HTMLElement): 'inline' | 'block' { + return root.classList.contains(DOM_CLASS_NAMES.IMAGE_FRAGMENT) ? 'block' : 'inline'; +} + +export class ImageInteractionLayer { + #container: HTMLElement | null = null; + + setContainer(container: HTMLElement | null): void { + this.#container = container; + } + + apply(layoutEpoch: number): void { + if (!this.#container) return; + + const epochStr = String(layoutEpoch); + for (const root of collectImageRoots(this.#container)) { + if (root.dataset[INTERACTION_EPOCH_KEY] === epochStr) continue; + + const pmStart = parsePmNumber(root.dataset.pmStart); + const pmEnd = parsePmNumber(root.dataset.pmEnd); + if (!pmStart || !pmEnd) continue; + + root.dataset[INTERACTION_EPOCH_KEY] = epochStr; + root.draggable = true; + root.dataset.dragSourceKind = 'existingImage'; + root.dataset.imageKind = resolveImageKind(root); + root.dataset.nodeType = 'image'; + root.dataset.displayLabel = resolveImageLabel(root); + root.dataset.pmStart = pmStart; + root.dataset.pmEnd = pmEnd; + } + } + + clear(): void { + if (!this.#container) return; + + for (const root of collectImageRoots(this.#container)) { + root.removeAttribute('draggable'); + delete root.dataset.dragSourceKind; + delete root.dataset.imageKind; + delete root.dataset.nodeType; + delete root.dataset.displayLabel; + delete root.dataset[INTERACTION_EPOCH_KEY]; + } + } +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.test.ts index c8b59b73f5..b99aaf7bfa 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.test.ts @@ -12,6 +12,16 @@ describe('PresentationPostPaintPipeline', () => { apply: vi.fn((layoutEpoch: number) => calls.push(`field:${layoutEpoch}`)), clear: vi.fn(), }, + imageLayer: { + setContainer: vi.fn(), + apply: vi.fn((layoutEpoch: number) => calls.push(`image:${layoutEpoch}`)), + clear: vi.fn(), + }, + structuredContentLayer: { + setContainer: vi.fn(), + apply: vi.fn((layoutEpoch: number) => calls.push(`structured:${layoutEpoch}`)), + clear: vi.fn(), + }, commentHighlightDecorator: { setContainer: vi.fn(), setActiveComment: vi.fn(() => false), @@ -47,7 +57,68 @@ describe('PresentationPostPaintPipeline', () => { reapplyStructuredContentHover: () => calls.push('hover'), }); - expect(calls).toEqual(['field:42', 'rebuild', 'comments', 'decorations', 'proofing', 'rebuild', 'hover']); + expect(calls).toEqual([ + 'field:42', + 'rebuild', + 'image:42', + 'structured:42', + 'comments', + 'decorations', + 'proofing', + 'rebuild', + 'hover', + ]); + }); + + it('applies structured content interactions after the DOM position index rebuild', () => { + const calls: string[] = []; + + const pipeline = new PresentationPostPaintPipeline({ + fieldAnnotationLayer: { + setContainer: vi.fn(), + apply: vi.fn(() => calls.push('field')), + clear: vi.fn(), + }, + structuredContentLayer: { + setContainer: vi.fn(), + apply: vi.fn(() => calls.push('structured')), + clear: vi.fn(), + }, + commentHighlightDecorator: { + setContainer: vi.fn(), + setActiveComment: vi.fn(() => false), + apply: vi.fn(() => calls.push('comments')), + destroy: vi.fn(), + }, + decorationBridge: { + recordTransaction: vi.fn(), + hasChanges: vi.fn(() => false), + collectDecorationRanges: vi.fn(() => []), + sync: vi.fn(() => { + calls.push('decorations'); + return false; + }), + destroy: vi.fn(), + }, + proofingDecorator: { + setContainer: vi.fn(), + applyAnnotations: vi.fn(() => { + calls.push('proofing'); + return false; + }), + clear: vi.fn(() => false), + }, + }); + + pipeline.refreshAfterPaint({ + layoutEpoch: 8, + editorState: {} as never, + domPositionIndex: {} as never, + proofingAnnotations: [], + rebuildDomPositionIndex: () => calls.push('rebuild'), + }); + + expect(calls).toEqual(['field', 'rebuild', 'structured', 'comments', 'decorations', 'proofing']); }); it('applies comment highlights before bridged decorations during inline style sync', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts index 617a837bd4..c666c19984 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts @@ -5,6 +5,8 @@ import type { ProofingAnnotation } from '../proofing/types.js'; import { CommentHighlightDecorator } from './CommentHighlightDecorator.js'; import { DecorationBridge } from './DecorationBridge.js'; import { FieldAnnotationInteractionLayer } from './FieldAnnotationInteractionLayer.js'; +import { ImageInteractionLayer } from './ImageInteractionLayer.js'; +import { StructuredContentInteractionLayer } from './StructuredContentInteractionLayer.js'; import { PresentationProofingDecorator } from './PresentationProofingDecorator.js'; type DecorationRange = { @@ -16,6 +18,8 @@ type DecorationRange = { }; type FieldAnnotationLayerLike = Pick; +type ImageLayerLike = Pick; +type StructuredContentLayerLike = Pick; type CommentHighlightDecoratorLike = Pick< CommentHighlightDecorator, 'setContainer' | 'setActiveComment' | 'apply' | 'destroy' @@ -28,6 +32,8 @@ type ProofingDecoratorLike = Pick { + let container: HTMLElement; + let layer: StructuredContentInteractionLayer; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + layer = new StructuredContentInteractionLayer(); + layer.setContainer(container); + }); + + afterEach(() => { + container.remove(); + }); + + it('marks only SDT title areas as draggable move sources', () => { + container.innerHTML = ` +
+ +

Body

+
+ + Inline Title + Value + + `; + + layer.apply(7); + + const blockLabel = container.querySelector('.superdoc-structured-content__label') as HTMLElement; + const inlineLabel = container.querySelector('.superdoc-structured-content-inline__label') as HTMLElement; + const body = container.querySelector('p') as HTMLElement; + + expect(blockLabel.draggable).toBe(true); + expect(blockLabel.dataset.dragSourceKind).toBe('structuredContent'); + expect(blockLabel.dataset.sdtId).toBe('block-1'); + expect(blockLabel.dataset.sdtScope).toBe('block'); + expect(blockLabel.dataset.lockMode).toBe('unlocked'); + + expect(inlineLabel.draggable).toBe(true); + expect(inlineLabel.dataset.dragSourceKind).toBe('structuredContent'); + expect(inlineLabel.dataset.sdtId).toBe('inline-1'); + expect(inlineLabel.dataset.sdtScope).toBe('inline'); + + expect(body.hasAttribute('draggable')).toBe(false); + }); + + it('clear removes SDT drag affordances', () => { + container.innerHTML = ` +
+ +
+ `; + + layer.apply(3); + const label = container.querySelector('.superdoc-structured-content__label') as HTMLElement; + expect(label.draggable).toBe(true); + + layer.clear(); + + expect(label.hasAttribute('draggable')).toBe(false); + expect(label.dataset.dragSourceKind).toBeUndefined(); + expect(label.dataset.sdtId).toBeUndefined(); + expect(label.dataset.pmStart).toBeUndefined(); + expect(label.dataset.pmEnd).toBeUndefined(); + expect(label.dataset.sdtScope).toBeUndefined(); + expect(label.dataset.lockMode).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/StructuredContentInteractionLayer.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/StructuredContentInteractionLayer.ts new file mode 100644 index 0000000000..33f7fcd70d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/StructuredContentInteractionLayer.ts @@ -0,0 +1,64 @@ +import { DOM_CLASS_NAMES, DATASET_KEYS } from '@superdoc/dom-contract'; + +const BLOCK_LABEL_SELECTOR = '.superdoc-structured-content__label'; +const INLINE_LABEL_SELECTOR = `.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; +const INTERACTION_EPOCH_KEY = 'structuredContentInteractionEpoch'; + +export class StructuredContentInteractionLayer { + #container: HTMLElement | null = null; + + setContainer(container: HTMLElement | null): void { + this.#container = container; + } + + apply(layoutEpoch: number): void { + if (!this.#container) return; + + const labels = Array.from( + this.#container.querySelectorAll(`${BLOCK_LABEL_SELECTOR}, ${INLINE_LABEL_SELECTOR}`), + ); + for (const label of labels) { + if (label.dataset[INTERACTION_EPOCH_KEY] === String(layoutEpoch)) continue; + + const sdtElement = label.closest( + `.${DOM_CLASS_NAMES.BLOCK_SDT}, .${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}`, + ) as HTMLElement | null; + if (!sdtElement?.dataset.sdtId || !sdtElement.dataset.pmStart || !sdtElement.dataset.pmEnd) continue; + + const scope = + sdtElement.dataset.sdtScope ?? (sdtElement.classList.contains(DOM_CLASS_NAMES.BLOCK_SDT) ? 'block' : 'inline'); + const labelText = label.textContent?.trim() || 'Structured content'; + + label.dataset[INTERACTION_EPOCH_KEY] = String(layoutEpoch); + label.draggable = true; + label.dataset.dragSourceKind = 'structuredContent'; + label.dataset.sdtId = sdtElement.dataset.sdtId; + label.dataset.pmStart = sdtElement.dataset.pmStart; + label.dataset.pmEnd = sdtElement.dataset.pmEnd; + label.dataset.sdtScope = scope; + label.dataset.lockMode = sdtElement.dataset.lockMode ?? 'unlocked'; + label.dataset[DATASET_KEYS.DISPLAY_LABEL] = labelText; + label.dataset.nodeType = scope === 'block' ? 'structuredContentBlock' : 'structuredContent'; + } + } + + clear(): void { + if (!this.#container) return; + + const labels = Array.from( + this.#container.querySelectorAll(`${BLOCK_LABEL_SELECTOR}, ${INLINE_LABEL_SELECTOR}`), + ); + for (const label of labels) { + label.removeAttribute('draggable'); + delete label.dataset.dragSourceKind; + delete label.dataset.sdtId; + delete label.dataset.pmStart; + delete label.dataset.pmEnd; + delete label.dataset.sdtScope; + delete label.dataset.lockMode; + delete label.dataset.nodeType; + delete label.dataset[DATASET_KEYS.DISPLAY_LABEL]; + delete label.dataset[INTERACTION_EPOCH_KEY]; + } + } +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts index e9f69bfd22..6898676f2c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts @@ -2,15 +2,24 @@ * DragDropManager - Consolidated drag and drop handling for PresentationEditor. * * This manager handles all drag/drop events for: - * - Field annotations (internal moves and external inserts) + * - Field annotations, structured content, and existing images (internal moves) * - Image files (drag from OS/other apps into the editor) * - Window-level fallback for drops on overlay elements */ import { TextSelection } from 'prosemirror-state'; import { DATASET_KEYS } from '@superdoc/dom-contract'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Editor } from '../../Editor.js'; import type { PositionHit } from '@superdoc/layout-bridge'; +import { + buildInternalObjectDragPayload, + INTERNAL_OBJECT_MIME_TYPE, + parseInternalObjectDragPayload, + type InternalObjectDragPayload, +} from './internal-drag-payloads.js'; +import { canInsertNodeAtPosition, createInternalNodeMoveTransaction } from './internal-node-move.js'; +import { findStructuredContentBlockById, findStructuredContentInlineById } from './structured-content-resolution.js'; // ============================================================================= // Constants @@ -27,7 +36,7 @@ export const FIELD_ANNOTATION_DATA_TYPE = 'fieldAnnotation' as const; // ============================================================================= /** Classifies what kind of data a drag event carries. */ -export type DropPayloadKind = 'fieldAnnotation' | 'imageFiles' | 'none'; +export type DropPayloadKind = 'fieldAnnotation' | 'internalObject' | 'imageFiles' | 'none'; /** * Attributes for a field annotation node. @@ -173,6 +182,12 @@ function isInternalDrag(event: DragEvent): boolean { return event.dataTransfer?.types?.includes(INTERNAL_MIME_TYPE) ?? false; } +function hasInternalObjectType(event: DragEvent): boolean { + if (!event.dataTransfer) return false; + const types = Array.from(event.dataTransfer.types ?? []).map((type) => type.toLowerCase()); + return types.includes(INTERNAL_OBJECT_MIME_TYPE.toLowerCase()); +} + /** * Extracts field annotation data from a drag event's dataTransfer. */ @@ -193,6 +208,35 @@ function extractDragData(event: DragEvent): FieldAnnotationDragData | null { } } +function resolveDragSourceElement(event: DragEvent): HTMLElement | null { + const target = event.target as HTMLElement | null; + return target?.closest?.('[data-drag-source-kind]') as HTMLElement | null; +} + +function resolveInternalObjectSourceRange( + doc: ProseMirrorNode, + payload: InternalObjectDragPayload, +): { sourceStart: number; sourceEnd: number } { + if (payload.kind === 'structuredContent') { + const resolved = + payload.nodeType === 'structuredContentBlock' + ? findStructuredContentBlockById(doc, payload.sdtId) + : findStructuredContentInlineById(doc, payload.sdtId); + + if (resolved) { + return { + sourceStart: resolved.pos, + sourceEnd: resolved.pos + resolved.node.nodeSize, + }; + } + } + + return { + sourceStart: payload.sourceStart, + sourceEnd: payload.sourceEnd, + }; +} + // ============================================================================= // Helpers — Payload Classification // ============================================================================= @@ -257,6 +301,7 @@ export function getDroppedImageFiles(event: DragEvent): File[] { */ export function getDropPayloadKind(event: DragEvent): DropPayloadKind { if (hasFieldAnnotationData(event)) return 'fieldAnnotation'; + if (hasInternalObjectType(event)) return 'internalObject'; if (hasPossibleFiles(event)) return 'imageFiles'; return 'none'; } @@ -269,6 +314,7 @@ export class DragDropManager { #deps: DragDropDependencies | null = null; #dragOverRaf: number | null = null; #pendingDragOver: { x: number; y: number } | null = null; + #activeInternalObjectPayload: InternalObjectDragPayload | null = null; // Bound handlers for cleanup #boundHandleDragStart: ((e: DragEvent) => void) | null = null; @@ -356,6 +402,7 @@ export class DragDropManager { destroy(): void { this.#cancelPendingDragOverSelection(); + this.#activeInternalObjectPayload = null; this.unbind(); this.#deps = null; } @@ -365,29 +412,48 @@ export class DragDropManager { // ========================================================================== /** - * Handle dragstart for internal field annotations. + * Handle dragstart for internal editor objects. */ #handleDragStart(event: DragEvent): void { - const target = event.target as HTMLElement; + const target = event.target as HTMLElement | null; + const sourceElement = resolveDragSourceElement(event); + const fieldAnnotationElement = target?.closest?.(`[${DATASET_KEYS.DRAGGABLE}="true"]`) as HTMLElement | null; - // Only handle draggable field annotations - if (!target?.dataset?.[DATASET_KEYS.DRAGGABLE] || target.dataset[DATASET_KEYS.DRAGGABLE] !== 'true') { + if (!target) { + this.#activeInternalObjectPayload = null; return; } - const data = extractFieldAnnotationData(target); + const internalObjectPayload = sourceElement ? buildInternalObjectDragPayload(sourceElement) : null; + const isInternalObjectSource = internalObjectPayload !== null; + const isFieldAnnotation = fieldAnnotationElement != null; + this.#activeInternalObjectPayload = isInternalObjectSource ? internalObjectPayload : null; + + if (!isFieldAnnotation && !isInternalObjectSource) { + this.#activeInternalObjectPayload = null; + return; + } if (event.dataTransfer) { - const jsonData = JSON.stringify({ - attributes: data.attributes, - sourceField: data, - }); + if (isInternalObjectSource && internalObjectPayload) { + const jsonData = JSON.stringify(internalObjectPayload); + event.dataTransfer.setData(INTERNAL_OBJECT_MIME_TYPE, jsonData); + event.dataTransfer.setData('text/plain', internalObjectPayload.label); + event.dataTransfer.setDragImage(sourceElement ?? target, 0, 0); + } else { + const draggableTarget = fieldAnnotationElement ?? target; + const data = extractFieldAnnotationData(draggableTarget); + 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); + // 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(draggableTarget, 0, 0); + } event.dataTransfer.effectAllowed = 'move'; } } @@ -409,6 +475,8 @@ export class DragDropManager { if (event.dataTransfer) { if (kind === 'fieldAnnotation') { event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + } else if (kind === 'internalObject') { + event.dataTransfer.dropEffect = 'move'; } else { event.dataTransfer.dropEffect = 'copy'; } @@ -439,7 +507,7 @@ export class DragDropManager { return; } - // Field annotation drop + // Internal editor object or field annotation drop const { state, view } = activeEditor; if (!state || !view) return; @@ -448,6 +516,15 @@ export class DragDropManager { const dropPos = hit?.pos ?? fallbackPos; if (dropPos == null) return; + if (kind === 'internalObject') { + try { + this.#handleInternalObjectDrop(event, dropPos); + } finally { + this.#activeInternalObjectPayload = null; + } + return; + } + if (isInternalDrag(event)) { this.#handleInternalDrop(event, dropPos); return; @@ -458,6 +535,7 @@ export class DragDropManager { #handleDragEnd(_event: DragEvent): void { this.#cancelPendingDragOverSelection(); + this.#activeInternalObjectPayload = null; this.#deps?.getPainterHost()?.classList.remove('drag-over'); } @@ -671,18 +749,54 @@ export class DragDropManager { if (sourceStart === null || sourceEnd === null || !sourceNode) return; - // Skip if dropping at same position - if (targetPos >= sourceStart && targetPos <= sourceEnd) return; + const result = createInternalNodeMoveTransaction( + { doc: state.doc, tr: state.tr }, + { + sourceStart, + sourceEnd, + targetPos, + expectedNodeType: 'fieldAnnotation', + // Field-annotation moves currently allow any insertion point. + canInsertAt: () => true, + }, + ); + + if (!result.ok) return; + view.dispatch(result.transaction); + } - // 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; + /** + * Handle internal structured-content / existing-image drops. + */ + #handleInternalObjectDrop(event: DragEvent, targetPos: number): void { + if (!this.#deps) return; + + const activeEditor = this.#deps.getActiveEditor(); + const { state, view } = activeEditor; + if (!state || !view) return; - tr.insert(mappedTarget, sourceNode); - tr.setMeta('uiEvent', 'drop'); - view.dispatch(tr); + const dataTransferPayload = parseInternalObjectDragPayload(event); + const payload = dataTransferPayload ?? this.#activeInternalObjectPayload; + if (!payload) return; + + const sourceRange = resolveInternalObjectSourceRange(state.doc, payload); + const { sourceStart, sourceEnd } = sourceRange; + + const result = createInternalNodeMoveTransaction( + { doc: state.doc, tr: state.tr }, + { + sourceStart, + sourceEnd, + targetPos, + expectedNodeType: payload.nodeType, + canInsertAt: canInsertNodeAtPosition, + }, + ); + + if (!result.ok) return; + view.dispatch(result.transaction); + this.#focusEditor(); + this.#deps.scheduleSelectionUpdate(); } /** @@ -751,6 +865,8 @@ export class DragDropManager { if (event.dataTransfer) { if (kind === 'fieldAnnotation') { event.dataTransfer.dropEffect = isInternalDrag(event) ? 'move' : 'copy'; + } else if (kind === 'internalObject') { + event.dataTransfer.dropEffect = 'move'; } else { event.dataTransfer.dropEffect = 'copy'; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.test.ts new file mode 100644 index 0000000000..2a929db495 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + buildExistingImageDragPayload, + buildInternalObjectDragPayload, + buildStructuredContentDragPayload, + INTERNAL_OBJECT_MIME_TYPE, + parseInternalObjectDragPayload, +} from './internal-drag-payloads.js'; + +describe('internal-drag-payloads', () => { + it('builds structured content payloads from visible label elements', () => { + const label = document.createElement('span'); + label.dataset.dragSourceKind = 'structuredContent'; + label.dataset.sdtId = 'sdt-1'; + label.dataset.pmStart = '10'; + label.dataset.pmEnd = '20'; + label.dataset.nodeType = 'structuredContentBlock'; + label.dataset.displayLabel = 'Customer Name'; + label.dataset.lockMode = 'unlocked'; + + expect(buildStructuredContentDragPayload(label)).toEqual({ + kind: 'structuredContent', + nodeType: 'structuredContentBlock', + sdtId: 'sdt-1', + label: 'Customer Name', + sourceStart: 10, + sourceEnd: 20, + lockMode: 'unlocked', + }); + expect(buildInternalObjectDragPayload(label)).toEqual({ + kind: 'structuredContent', + nodeType: 'structuredContentBlock', + sdtId: 'sdt-1', + label: 'Customer Name', + sourceStart: 10, + sourceEnd: 20, + lockMode: 'unlocked', + }); + }); + + it('builds image payloads from rendered image roots', () => { + const image = document.createElement('div'); + image.className = 'superdoc-image-fragment'; + image.dataset.dragSourceKind = 'existingImage'; + image.dataset.imageKind = 'block'; + image.dataset.nodeType = 'image'; + image.dataset.pmStart = '40'; + image.dataset.pmEnd = '46'; + image.dataset.displayLabel = 'Receipt image'; + image.setAttribute('data-block-id', 'image-1'); + + expect(buildExistingImageDragPayload(image)).toEqual({ + kind: 'existingImage', + imageKind: 'block', + nodeType: 'image', + sourceStart: 40, + sourceEnd: 46, + blockId: 'image-1', + label: 'Receipt image', + }); + expect(buildInternalObjectDragPayload(image)).toEqual({ + kind: 'existingImage', + imageKind: 'block', + nodeType: 'image', + sourceStart: 40, + sourceEnd: 46, + blockId: 'image-1', + label: 'Receipt image', + }); + }); + + it('ignores invalid source elements', () => { + const element = document.createElement('div'); + element.dataset.dragSourceKind = 'structuredContent'; + expect(buildInternalObjectDragPayload(element)).toBeNull(); + }); + + it('parses internal object payloads from data transfer', () => { + const event = new MouseEvent('dragover') as DragEvent; + Object.defineProperty(event, 'dataTransfer', { + value: { + types: [INTERNAL_OBJECT_MIME_TYPE], + getData: (mimeType: string) => + mimeType === INTERNAL_OBJECT_MIME_TYPE + ? JSON.stringify({ + kind: 'structuredContent', + nodeType: 'structuredContentBlock', + sdtId: 'sdt-1', + label: 'Customer Name', + sourceStart: 10, + sourceEnd: 20, + lockMode: 'unlocked', + }) + : '', + }, + }); + + expect(parseInternalObjectDragPayload(event)).toEqual({ + kind: 'structuredContent', + nodeType: 'structuredContentBlock', + sdtId: 'sdt-1', + label: 'Customer Name', + sourceStart: 10, + sourceEnd: 20, + lockMode: 'unlocked', + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.ts new file mode 100644 index 0000000000..06261b5707 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-drag-payloads.ts @@ -0,0 +1,147 @@ +// @todo: Eventually some of these utility helpers should be extracted +// to a more general package/file for use across multiple editor implementations +// I want to avoid scope creep in these initial changes +export const INTERNAL_OBJECT_MIME_TYPE = 'application/x-superdoc-internal-object'; + +export type InternalDragSourceKind = 'structuredContent' | 'existingImage'; + +export type StructuredContentDragPayload = { + kind: 'structuredContent'; + nodeType: 'structuredContent' | 'structuredContentBlock'; + sdtId: string; + label: string; + sourceStart: number; + sourceEnd: number; + lockMode: string; +}; + +export type ExistingImageDragPayload = { + kind: 'existingImage'; + imageKind: 'inline' | 'block'; + nodeType: string; + sourceStart: number; + sourceEnd: number; + blockId?: string; + label: string; +}; + +export type InternalObjectDragPayload = StructuredContentDragPayload | ExistingImageDragPayload; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function readNumber(value: unknown): number | null { + if (isFiniteNumber(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export function parseInternalObjectDragPayload(event: DragEvent): InternalObjectDragPayload | null { + const raw = event.dataTransfer?.getData(INTERNAL_OBJECT_MIME_TYPE); + if (!raw) return null; + + try { + const parsed: unknown = JSON.parse(raw); + if (isStructuredContentPayload(parsed) || isExistingImagePayload(parsed)) { + return parsed; + } + } catch { + return null; + } + + return null; +} + +export function buildStructuredContentDragPayload(sourceElement: HTMLElement): StructuredContentDragPayload | null { + const dataset = sourceElement.dataset; + const sdtId = readString(dataset.sdtId); + const sourceStart = readNumber(dataset.pmStart); + const sourceEnd = readNumber(dataset.pmEnd); + if (!sdtId || sourceStart == null || sourceEnd == null) return null; + + return { + kind: 'structuredContent', + nodeType: dataset.nodeType === 'structuredContentBlock' ? 'structuredContentBlock' : 'structuredContent', + sdtId, + label: readString(dataset.displayLabel) ?? sourceElement.textContent?.trim() ?? 'Structured content', + sourceStart, + sourceEnd, + lockMode: readString(dataset.lockMode) ?? 'unlocked', + }; +} + +export function buildExistingImageDragPayload(sourceElement: HTMLElement): ExistingImageDragPayload | null { + const dataset = sourceElement.dataset; + const sourceStart = readNumber(dataset.pmStart); + const sourceEnd = readNumber(dataset.pmEnd); + if (sourceStart == null || sourceEnd == null) return null; + + return { + kind: 'existingImage', + imageKind: dataset.imageKind === 'block' ? 'block' : 'inline', + nodeType: dataset.nodeType ?? 'image', + sourceStart, + sourceEnd, + blockId: + readString(dataset.blockId) ?? + readString(sourceElement.getAttribute('data-block-id')) ?? + readString(sourceElement.getAttribute('data-sd-block-id')) ?? + undefined, + label: readString(dataset.displayLabel) ?? sourceElement.getAttribute('aria-label') ?? 'Image', + }; +} + +export function buildInternalObjectDragPayload(sourceElement: HTMLElement): InternalObjectDragPayload | null { + const sourceKind = sourceElement.dataset.dragSourceKind; + if (sourceKind === 'structuredContent') { + return buildStructuredContentDragPayload(sourceElement); + } + if (sourceKind === 'existingImage') { + return buildExistingImageDragPayload(sourceElement); + } + return null; +} + +export function hasInternalObjectDragPayload(event: DragEvent): boolean { + return parseInternalObjectDragPayload(event) !== null; +} + +function isStructuredContentPayload(value: unknown): value is StructuredContentDragPayload { + if (!isObject(value)) return false; + + return ( + value.kind === 'structuredContent' && + (value.nodeType === 'structuredContent' || value.nodeType === 'structuredContentBlock') && + typeof value.sdtId === 'string' && + typeof value.label === 'string' && + isFiniteNumber(value.sourceStart) && + isFiniteNumber(value.sourceEnd) && + typeof value.lockMode === 'string' + ); +} + +function isExistingImagePayload(value: unknown): value is ExistingImageDragPayload { + if (!isObject(value)) return false; + + return ( + value.kind === 'existingImage' && + (value.imageKind === 'inline' || value.imageKind === 'block') && + typeof value.nodeType === 'string' && + isFiniteNumber(value.sourceStart) && + isFiniteNumber(value.sourceEnd) && + typeof value.label === 'string' && + (value.blockId === undefined || typeof value.blockId === 'string') + ); +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts new file mode 100644 index 0000000000..fb5c61a54b --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createInternalNodeMoveTransaction } from './internal-node-move.js'; + +function createMoveState(options?: { + sourceNodeType?: string; + sourceNodeSize?: number; + mappedTarget?: number; + canInsertAt?: boolean; +}) { + const sourceNode = { + type: { name: options?.sourceNodeType ?? 'structuredContentBlock' }, + nodeSize: options?.sourceNodeSize ?? 10, + }; + + const doc = { + content: { size: 200 }, + nodeAt: vi.fn((pos: number) => (pos === 20 ? sourceNode : null)), + resolve: vi.fn(() => ({ + depth: 0, + node: () => ({ + canReplaceWith: vi.fn(() => options?.canInsertAt ?? true), + }), + index: vi.fn(() => 0), + })), + }; + + const tr = { + doc: { content: { size: 200 } }, + delete: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { + map: vi.fn(() => options?.mappedTarget ?? 80), + }, + }; + + return { doc, tr, sourceNode }; +} + +describe('createInternalNodeMoveTransaction', () => { + it('moves a node when the source and target are valid', () => { + const { doc, tr, sourceNode } = createMoveState(); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 20, + sourceEnd: 30, + targetPos: 80, + canInsertAt: () => true, + }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.transaction).toBe(tr); + expect(result.mappedTarget).toBe(80); + } + expect(doc.nodeAt).toHaveBeenCalledWith(20); + expect(tr.delete).toHaveBeenCalledWith(20, 30); + expect(tr.insert).toHaveBeenCalledWith(80, sourceNode); + expect(tr.setMeta).toHaveBeenCalledWith('uiEvent', 'drop'); + }); + + it('rejects drops inside the source range', () => { + const { doc, tr } = createMoveState(); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 20, + sourceEnd: 30, + targetPos: 25, + canInsertAt: () => true, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('same-range'); + } + expect(tr.delete).not.toHaveBeenCalled(); + expect(tr.insert).not.toHaveBeenCalled(); + }); + + it('rejects when the source node type does not match', () => { + const { doc, tr } = createMoveState({ sourceNodeType: 'image' }); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 20, + sourceEnd: 30, + targetPos: 80, + expectedNodeType: 'structuredContentBlock', + canInsertAt: () => true, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('wrong-node-type'); + } + expect(tr.delete).not.toHaveBeenCalled(); + }); + + it('rejects when the target cannot accept the node', () => { + const { doc, tr } = createMoveState(); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 20, + sourceEnd: 30, + targetPos: 80, + canInsertAt: () => false, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('invalid-target'); + } + expect(tr.insert).not.toHaveBeenCalled(); + }); + + it('moves a block node to the nearest valid sibling boundary when dropped inside text content', () => { + const { doc, tr, sourceNode } = createMoveState({ mappedTarget: 80 }); + doc.nodeAt.mockImplementation((pos: number) => (pos === 120 ? sourceNode : null)); + tr.doc = { + content: { size: 200 }, + resolve: vi.fn(() => ({ + depth: 1, + before: vi.fn(() => 70), + after: vi.fn(() => 90), + })), + } as never; + const canInsertAt = vi.fn((_doc, pos: number) => pos === 70); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 120, + sourceEnd: 130, + targetPos: 80, + canInsertAt, + }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.mappedTarget).toBe(70); + } + expect(canInsertAt).toHaveBeenCalledWith(tr.doc, 80, sourceNode); + expect(canInsertAt).toHaveBeenCalledWith(tr.doc, 70, sourceNode); + expect(tr.insert).toHaveBeenCalledWith(70, sourceNode); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts new file mode 100644 index 0000000000..5da4ea8646 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts @@ -0,0 +1,117 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Transaction } from 'prosemirror-state'; + +export type InternalMoveRequest = { + sourceStart: number; + sourceEnd: number; + targetPos: number; + expectedNodeType?: string; + canInsertAt: (doc: ProseMirrorNode, pos: number, node: ProseMirrorNode) => boolean; +}; + +export type InternalMoveResult = + | { ok: true; transaction: Transaction; mappedTarget: number } + | { ok: false; reason: 'invalid-source' | 'same-range' | 'wrong-node-type' | 'invalid-target' }; + +type InternalMoveState = { + doc: ProseMirrorNode; + tr: Transaction; +}; + +type TargetBias = 'before' | 'after'; + +export function canInsertNodeAtPosition(doc: ProseMirrorNode, pos: number, node: ProseMirrorNode): boolean { + try { + const resolvedPos = doc.resolve(pos); + const { parent } = resolvedPos; + const index = resolvedPos.index(); + + if (typeof parent.canReplaceWith === 'function') { + return parent.canReplaceWith(index, index, node.type); + } + + return Boolean(parent.type.contentMatch.matchType(node.type)); + } catch { + return false; + } +} + +function resolveInsertionBoundary( + doc: ProseMirrorNode, + pos: number, + node: ProseMirrorNode, + canInsertAt: InternalMoveRequest['canInsertAt'], + bias: TargetBias, +): number | null { + try { + const resolvedPos = doc.resolve(pos); + const candidates: number[] = []; + + for (let depth = resolvedPos.depth; depth > 0; depth--) { + const before = resolvedPos.before(depth); + const after = resolvedPos.after(depth); + if (bias === 'before') { + candidates.push(before, after); + } else { + candidates.push(after, before); + } + } + + for (const candidate of candidates) { + if (candidate < 0 || candidate > doc.content.size) continue; + if (candidate === pos) continue; + if (canInsertAt(doc, candidate, node)) return candidate; + } + } catch { + return null; + } + + return null; +} + +export function createInternalNodeMoveTransaction( + state: InternalMoveState, + request: InternalMoveRequest, +): InternalMoveResult { + const { sourceStart, sourceEnd, targetPos, expectedNodeType, canInsertAt } = request; + + if (targetPos >= sourceStart && targetPos <= sourceEnd) { + return { ok: false, reason: 'same-range' }; + } + + const sourceNode = state.doc.nodeAt(sourceStart); + if (!sourceNode || sourceEnd !== sourceStart + sourceNode.nodeSize) { + return { ok: false, reason: 'invalid-source' }; + } + + if (expectedNodeType && sourceNode.type.name !== expectedNodeType) { + return { ok: false, reason: 'wrong-node-type' }; + } + + const tr = state.tr; + tr.delete(sourceStart, sourceEnd); + + const mappedTarget = tr.mapping.map(targetPos); + if (mappedTarget < 0 || mappedTarget > tr.doc.content.size) { + return { ok: false, reason: 'invalid-target' }; + } + + let insertTarget = mappedTarget; + if (!canInsertAt(tr.doc, insertTarget, sourceNode)) { + const boundaryTarget = resolveInsertionBoundary( + tr.doc, + insertTarget, + sourceNode, + canInsertAt, + targetPos <= sourceStart ? 'before' : 'after', + ); + if (boundaryTarget == null) { + return { ok: false, reason: 'invalid-target' }; + } + insertTarget = boundaryTarget; + } + + tr.insert(insertTarget, sourceNode); + tr.setMeta('uiEvent', 'drop'); + return { ok: true, transaction: tr, mappedTarget: insertTarget }; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/structured-content-resolution.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/structured-content-resolution.ts new file mode 100644 index 0000000000..d9fe5e0272 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/structured-content-resolution.ts @@ -0,0 +1,111 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; + +export type StructuredContentSelection = { + node: ProseMirrorNode; + pos: number; + start: number; + end: number; +}; + +function matchesStructuredContentId(node: ProseMirrorNode, id: string): boolean { + if (!id) return false; + const attrs = node.attrs as { id?: unknown; sdtId?: unknown } | null | undefined; + const nodeId = attrs?.id; + const nodeSdtId = attrs?.sdtId; + + return (nodeId != null && String(nodeId) === id) || (nodeSdtId != null && String(nodeSdtId) === id); +} + +function resolvePosSafely(doc: ProseMirrorNode, pos: number): ReturnType | null { + if (!Number.isInteger(pos)) return null; + + try { + return doc.resolve(pos); + } catch { + return null; + } +} + +export function findStructuredContentBlockAtPos(doc: ProseMirrorNode, pos: number): StructuredContentSelection | null { + if (!Number.isFinite(pos)) return null; + + const $pos = resolvePosSafely(doc, pos); + if (!$pos) return null; + + for (let depth = $pos.depth; depth > 0; depth--) { + const node = $pos.node(depth); + if (node.type?.name === 'structuredContentBlock') { + return { + node, + pos: $pos.before(depth), + start: $pos.start(depth), + end: $pos.end(depth), + }; + } + } + + return null; +} + +export function findStructuredContentBlockById(doc: ProseMirrorNode, id: string): StructuredContentSelection | null { + if (!id) return null; + + let found: StructuredContentSelection | null = null; + + doc.descendants((node, pos) => { + if (node.type?.name !== 'structuredContentBlock') return true; + if (!matchesStructuredContentId(node, id)) return true; + + found = { + node, + pos, + start: pos + 1, + end: pos + node.nodeSize - 1, + }; + return false; + }); + + return found; +} + +export function findStructuredContentInlineAtPos(doc: ProseMirrorNode, pos: number): StructuredContentSelection | null { + if (!Number.isFinite(pos)) return null; + + const $pos = resolvePosSafely(doc, pos); + if (!$pos) return null; + + for (let depth = $pos.depth; depth > 0; depth--) { + const node = $pos.node(depth); + if (node.type?.name === 'structuredContent') { + return { + node, + pos: $pos.before(depth), + start: $pos.start(depth), + end: $pos.end(depth), + }; + } + } + + return null; +} + +export function findStructuredContentInlineById(doc: ProseMirrorNode, id: string): StructuredContentSelection | null { + if (!id) return null; + + let found: StructuredContentSelection | null = null; + + doc.descendants((node, pos) => { + if (node.type?.name !== 'structuredContent') return true; + if (!matchesStructuredContentId(node, id)) return true; + + found = { + node, + pos, + start: pos + 1, + end: pos + node.nodeSize - 1, + }; + return false; + }); + + return found; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index fb5d17840b..55c3527f2e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -41,6 +41,13 @@ import { debugLog } from '../selection/SelectionDebug.js'; import { DOM_CLASS_NAMES, buildAnnotationSelector, DRAGGABLE_SELECTOR } from '@superdoc/dom-contract'; import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; +import { + findStructuredContentBlockAtPos, + findStructuredContentBlockById, + findStructuredContentInlineAtPos, + findStructuredContentInlineById, + type StructuredContentSelection, +} from '../input/structured-content-resolution.js'; // ============================================================================= // Constants @@ -64,6 +71,7 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray Math.max(min, Math.min(max, value)); +const DRAG_SOURCE_SELECTOR = '[data-draggable="true"], [data-drag-source-kind]'; type CommentThreadHit = { isAmbiguous: boolean; @@ -244,13 +252,6 @@ export type LayoutState = { measures: Measure[]; }; -type StructuredContentSelection = { - node: ProseMirrorNode; - pos: number; - start: number; - end: number; -}; - /** * Dependencies injected from PresentationEditor. */ @@ -1043,7 +1044,9 @@ export class EditorInputManager { // Handle field annotation clicks const annotationEl = target?.closest?.(buildAnnotationSelector()) as HTMLElement | null; const isDraggableAnnotation = target?.closest?.(DRAGGABLE_SELECTOR) != null; - this.#suppressFocusInFromDraggable = isDraggableAnnotation; + const isNativeDragSource = target?.closest?.(DRAG_SOURCE_SELECTOR) != null; + const suppressFocusForDrag = isDraggableAnnotation || isNativeDragSource; + this.#suppressFocusInFromDraggable = suppressFocusForDrag; if (annotationEl) { this.#handleAnnotationClick(event, annotationEl); @@ -1061,7 +1064,7 @@ export class EditorInputManager { const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) { - this.#handleClickWithoutLayout(event, isDraggableAnnotation); + this.#handleClickWithoutLayout(event, suppressFocusForDrag); return; } @@ -1075,7 +1078,7 @@ export class EditorInputManager { const fragmentEl = target?.closest?.('[data-block-id]') as HTMLElement | null; const clickedBlockId = fragmentEl?.getAttribute?.('data-block-id') ?? ''; if (isFootnoteBlockId(clickedBlockId)) { - if (!isDraggableAnnotation) event.preventDefault(); + if (!suppressFocusForDrag) event.preventDefault(); this.#focusEditor(); return; } @@ -1133,7 +1136,7 @@ export class EditorInputManager { this.#callbacks.updateSelectionDebugHud?.(); // Don't preventDefault for draggable annotations - if (!isDraggableAnnotation) { + if (!suppressFocusForDrag) { event.preventDefault(); } @@ -1292,7 +1295,7 @@ export class EditorInputManager { // SD-1584: clicking inside a block SDT selects the node (NodeSelection). // Exception: clicks inside tables nested in this SDT should use text // selection so caret placement/editing inside table cells works. - const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null; + const sdtBlock = clickDepth === 1 ? findStructuredContentBlockAtPos(doc, hit.pos) : null; let nextSelection: Selection; const insideTableInSdt = !!sdtBlock && this.#isInsideTableWithinStructuredContentBlock(doc, hit.pos, sdtBlock.pos); @@ -1582,25 +1585,6 @@ export class EditorInputManager { } } - #findStructuredContentBlockAtPos(doc: ProseMirrorNode, pos: number): StructuredContentSelection | null { - if (!Number.isFinite(pos)) return null; - - const $pos = doc.resolve(pos); - for (let depth = $pos.depth; depth > 0; depth--) { - const node = $pos.node(depth); - if (node.type?.name === 'structuredContentBlock') { - return { - node, - pos: $pos.before(depth), - start: $pos.start(depth), - end: $pos.end(depth), - }; - } - } - - return null; - } - #isInsideTableWithinStructuredContentBlock(doc: ProseMirrorNode, pos: number, sdtPos: number): boolean { if (!Number.isFinite(pos) || !Number.isFinite(sdtPos)) return false; @@ -1629,61 +1613,6 @@ export class EditorInputManager { } } - #findStructuredContentBlockById(doc: ProseMirrorNode, id: string): StructuredContentSelection | null { - let found: StructuredContentSelection | null = null; - doc.descendants((node, pos) => { - if (node.type?.name !== 'structuredContentBlock') return true; - const nodeId = (node.attrs as { id?: unknown } | null | undefined)?.id; - if (String(nodeId ?? '') !== id) return true; - - found = { - node, - pos, - start: pos + 1, - end: pos + node.nodeSize - 1, - }; - return false; - }); - return found; - } - - #findStructuredContentInlineAtPos(doc: ProseMirrorNode, pos: number): StructuredContentSelection | null { - if (!Number.isFinite(pos)) return null; - - const $pos = doc.resolve(pos); - for (let depth = $pos.depth; depth > 0; depth--) { - const node = $pos.node(depth); - if (node.type?.name === 'structuredContent') { - return { - node, - pos: $pos.before(depth), - start: $pos.start(depth), - end: $pos.end(depth), - }; - } - } - - return null; - } - - #findStructuredContentInlineById(doc: ProseMirrorNode, id: string): StructuredContentSelection | null { - let found: StructuredContentSelection | null = null; - doc.descendants((node, pos) => { - if (node.type?.name !== 'structuredContent') return true; - const nodeId = (node.attrs as { id?: unknown } | null | undefined)?.id; - if (String(nodeId ?? '') !== id) return true; - - found = { - node, - pos, - start: pos + 1, - end: pos + node.nodeSize - 1, - }; - return false; - }); - return found; - } - #resolveStructuredContentBlockFromElement( doc: ProseMirrorNode, element: HTMLElement, @@ -1693,20 +1622,20 @@ export class EditorInputManager { const sdtId = container.dataset?.sdtId; if (sdtId) { - const match = this.#findStructuredContentBlockById(doc, sdtId); + const match = findStructuredContentBlockById(doc, sdtId); if (match) return match; } const containerSdtId = container.dataset?.sdtContainerId; if (containerSdtId) { - const match = this.#findStructuredContentBlockById(doc, containerSdtId); + const match = findStructuredContentBlockById(doc, containerSdtId); if (match) return match; } const pmStartRaw = container.dataset?.pmStart; const pmStart = pmStartRaw != null ? Number(pmStartRaw) : NaN; if (Number.isFinite(pmStart)) { - return this.#findStructuredContentBlockAtPos(doc, pmStart); + return findStructuredContentBlockAtPos(doc, pmStart); } return null; @@ -1721,14 +1650,14 @@ export class EditorInputManager { const sdtId = container.dataset?.sdtId; if (sdtId) { - const match = this.#findStructuredContentInlineById(doc, sdtId); + const match = findStructuredContentInlineById(doc, sdtId); if (match) return match; } const pmStartRaw = container.dataset?.pmStart; const pmStart = pmStartRaw != null ? Number(pmStartRaw) : NaN; if (Number.isFinite(pmStart)) { - return this.#findStructuredContentInlineAtPos(doc, pmStart); + return findStructuredContentInlineAtPos(doc, pmStart); } return null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts index a5ac07dcfc..d942ffd734 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts @@ -144,6 +144,107 @@ function createImageDragEvent( return event; } +/** + * Creates a mock DragEvent with an internal object payload. + */ +function createInternalObjectDragEvent(type: string, payload: Record): DragEvent { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + }) as DragEvent; + + Object.defineProperty(event, 'dataTransfer', { + value: { + types: ['application/x-superdoc-internal-object'], + files: { length: 0, item: () => null }, + getData: vi.fn((mimeType: string) => { + if (mimeType === 'application/x-superdoc-internal-object') { + return JSON.stringify(payload); + } + return ''; + }), + setData: vi.fn(), + dropEffect: 'none' as DataTransferDropEffect, + effectAllowed: 'all' as DataTransferEffectAllowed, + }, + writable: false, + }); + + return event; +} + +function createInternalObjectDragOverEvent(type: string): DragEvent { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + }) as DragEvent; + + Object.defineProperty(event, 'dataTransfer', { + value: { + types: ['application/x-superdoc-internal-object'], + files: { length: 0, item: () => null }, + getData: vi.fn(() => ''), + setData: vi.fn(), + dropEffect: 'none' as DataTransferDropEffect, + effectAllowed: 'all' as DataTransferEffectAllowed, + }, + writable: false, + }); + + return event; +} + +function createInternalObjectDragStartEvent(): DragEvent { + const event = new MouseEvent('dragstart', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + }) as DragEvent; + + Object.defineProperty(event, 'dataTransfer', { + value: { + types: [], + files: { length: 0, item: () => null }, + getData: vi.fn(() => ''), + setData: vi.fn(), + setDragImage: vi.fn(), + dropEffect: 'none' as DataTransferDropEffect, + effectAllowed: 'all' as DataTransferEffectAllowed, + }, + writable: false, + }); + + return event; +} + +function createInternalObjectDropEventWithEmptyDataTransfer(): DragEvent { + const event = new MouseEvent('drop', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200, + }) as DragEvent; + + Object.defineProperty(event, 'dataTransfer', { + value: { + types: ['application/x-superdoc-internal-object'], + files: { length: 0, item: () => null }, + getData: vi.fn(() => ''), + setData: vi.fn(), + dropEffect: 'move' as DataTransferDropEffect, + effectAllowed: 'move' as DataTransferEffectAllowed, + }, + writable: false, + }); + + return event; +} + /** * Creates a mock DragEvent with no recognized payload. */ @@ -181,6 +282,12 @@ describe('Payload classification helpers', () => { expect(getDropPayloadKind(event)).toBe('fieldAnnotation'); }); + it('returns "internalObject" for internal object dragover payloads even before JSON is readable', () => { + const event = createInternalObjectDragOverEvent('dragover'); + + expect(getDropPayloadKind(event)).toBe('internalObject'); + }); + it('returns "imageFiles" for image file payloads', () => { const event = createImageDragEvent('dragover'); expect(getDropPayloadKind(event)).toBe('imageFiles'); @@ -279,7 +386,7 @@ describe('DragDropManager', () => { isEditable: boolean; options: Record; state: { - doc: { content: { size: number }; nodeAt: Mock }; + doc: { content: { size: number }; nodeAt: Mock; descendants: Mock }; tr: { setSelection: Mock; setMeta: Mock }; selection: { from: number; to: number }; }; @@ -322,7 +429,7 @@ describe('DragDropManager', () => { isEditable: true, options: {}, state: { - doc: { content: { size: 100 }, nodeAt: vi.fn() }, + doc: { content: { size: 100 }, nodeAt: vi.fn(), descendants: vi.fn() }, tr: mockTr, selection: { from: 0, to: 0 }, }, @@ -544,6 +651,102 @@ describe('DragDropManager', () => { }); }); + // ========================================================================== + // Internal Object Drop + // ========================================================================== + + describe('internal object drop', () => { + it('uses the active drag payload when drop getData is empty', () => { + const sourceNode = { + type: { name: 'image' }, + nodeSize: 1, + }; + const tr = mockEditor.state.tr as typeof mockEditor.state.tr & { + doc: { content: { size: number }; resolve: Mock }; + delete: Mock; + insert: Mock; + mapping: { map: Mock }; + }; + tr.doc = { + content: { size: 100 }, + resolve: vi.fn(() => ({ + parent: { + canReplaceWith: vi.fn(() => true), + }, + index: vi.fn(() => 0), + })), + }; + tr.delete = vi.fn().mockReturnThis(); + tr.insert = vi.fn().mockReturnThis(); + tr.mapping = { map: vi.fn(() => 50) }; + mockEditor.state.doc.nodeAt.mockImplementation((pos: number) => (pos === 20 ? sourceNode : null)); + + const sourceElement = document.createElement('img'); + sourceElement.dataset.dragSourceKind = 'existingImage'; + sourceElement.dataset.imageKind = 'inline'; + sourceElement.dataset.nodeType = 'image'; + sourceElement.dataset.pmStart = '20'; + sourceElement.dataset.pmEnd = '21'; + sourceElement.dataset.displayLabel = 'Picture 1'; + painterHost.appendChild(sourceElement); + + sourceElement.dispatchEvent(createInternalObjectDragStartEvent()); + viewportHost.dispatchEvent(createInternalObjectDropEventWithEmptyDataTransfer()); + + expect(tr.delete).toHaveBeenCalledWith(20, 21); + expect(tr.insert).toHaveBeenCalledWith(50, sourceNode); + expect(mockEditor.view.dispatch).toHaveBeenCalledWith(tr); + expect(scheduleSelectionUpdateMock).toHaveBeenCalled(); + }); + + it('resolves structured content source nodes by SDT id before moving', () => { + const sourceNode = { + type: { name: 'structuredContentBlock' }, + attrs: { id: '1140082372' }, + nodeSize: 348, + }; + const tr = mockEditor.state.tr as typeof mockEditor.state.tr & { + doc: { content: { size: number }; resolve: Mock }; + delete: Mock; + insert: Mock; + mapping: { map: Mock }; + }; + tr.doc = { + content: { size: 1000 }, + resolve: vi.fn(() => ({ + parent: { + canReplaceWith: vi.fn(() => true), + }, + index: vi.fn(() => 0), + })), + }; + tr.delete = vi.fn().mockReturnThis(); + tr.insert = vi.fn().mockReturnThis(); + tr.mapping = { map: vi.fn(() => 134) }; + mockEditor.state.doc.nodeAt.mockImplementation((pos: number) => (pos === 186 ? sourceNode : null)); + mockEditor.state.doc.descendants.mockImplementation((callback: (node: unknown, pos: number) => boolean) => { + callback(sourceNode, 186); + }); + + viewportHost.dispatchEvent( + createInternalObjectDragEvent('drop', { + kind: 'structuredContent', + nodeType: 'structuredContentBlock', + sdtId: '1140082372', + label: 'Signature', + sourceStart: 187, + sourceEnd: 535, + lockMode: 'unlocked', + }), + ); + + expect(tr.delete).toHaveBeenCalledWith(186, 534); + expect(tr.insert).toHaveBeenCalledWith(134, sourceNode); + expect(mockEditor.view.dispatch).toHaveBeenCalledWith(tr); + expect(scheduleSelectionUpdateMock).toHaveBeenCalled(); + }); + }); + // ========================================================================== // hitTest Failure Fallback // ========================================================================== diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts index 2dcff9abe3..ca37307314 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts @@ -299,6 +299,14 @@ describe('PresentationEditor - Draggable Annotation Focus Suppression (SD-1179)' // Clicking on the deeply nested span should still recognize the draggable parent expect(deepSpan.closest('[data-draggable="true"]')).toBe(annotation); }); + + it('should recognize elements with data-drag-source-kind attribute', () => { + const source = document.createElement('div'); + source.setAttribute('data-drag-source-kind', 'structuredContent'); + + expect(source.closest('[data-drag-source-kind]')).toBe(source); + expect(source.closest('[data-draggable="true"]')).toBeNull(); + }); }); describe('editor initialization with draggable support', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/structured-content-resolution.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/structured-content-resolution.test.ts new file mode 100644 index 0000000000..8a29f66fe0 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/structured-content-resolution.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + findStructuredContentBlockAtPos, + findStructuredContentBlockById, + findStructuredContentInlineAtPos, + findStructuredContentInlineById, +} from '../input/structured-content-resolution.js'; + +describe('structured content resolution helpers', () => { + it('finds structured content blocks by id using id or sdtId attrs', () => { + const blockById = { + type: { name: 'structuredContentBlock' }, + attrs: { id: 'block-1' }, + nodeSize: 8, + }; + const blockBySdtId = { + type: { name: 'structuredContentBlock' }, + attrs: { sdtId: 'block-2' }, + nodeSize: 10, + }; + const doc = { + descendants: vi.fn((callback: (node: unknown, pos: number) => boolean) => { + if (callback(blockById, 5) === false) return; + callback(blockBySdtId, 20); + }), + }; + + expect(findStructuredContentBlockById(doc as never, 'block-1')).toEqual({ + node: blockById, + pos: 5, + start: 6, + end: 12, + }); + expect(findStructuredContentBlockById(doc as never, 'block-2')).toEqual({ + node: blockBySdtId, + pos: 20, + start: 21, + end: 29, + }); + }); + + it('finds inline structured content by id using id or sdtId attrs', () => { + const inlineNode = { + type: { name: 'structuredContent' }, + attrs: { sdtId: 'inline-1' }, + nodeSize: 6, + }; + const doc = { + descendants: vi.fn((callback: (node: unknown, pos: number) => boolean) => { + callback(inlineNode, 14); + }), + }; + + expect(findStructuredContentInlineById(doc as never, 'inline-1')).toEqual({ + node: inlineNode, + pos: 14, + start: 15, + end: 19, + }); + }); + + it('finds structured content blocks and inlines at a resolved position', () => { + const blockNode = { type: { name: 'structuredContentBlock' } }; + const inlineNode = { type: { name: 'structuredContent' } }; + const doc = { + resolve: vi + .fn() + .mockReturnValueOnce({ + depth: 2, + node: (depth: number) => { + if (depth === 2) return { type: { name: 'paragraph' } }; + if (depth === 1) return blockNode; + return { type: { name: 'doc' } }; + }, + before: (depth: number) => (depth === 1 ? 10 : 11), + start: (depth: number) => (depth === 1 ? 11 : 12), + end: (depth: number) => (depth === 1 ? 30 : 29), + }) + .mockReturnValueOnce({ + depth: 2, + node: (depth: number) => { + if (depth === 2) return inlineNode; + if (depth === 1) return { type: { name: 'paragraph' } }; + return { type: { name: 'doc' } }; + }, + before: (depth: number) => (depth === 2 ? 22 : 20), + start: (depth: number) => (depth === 2 ? 23 : 21), + end: (depth: number) => (depth === 2 ? 26 : 28), + }), + }; + + expect(findStructuredContentBlockAtPos(doc as never, 15)).toEqual({ + node: blockNode, + pos: 10, + start: 11, + end: 30, + }); + expect(findStructuredContentInlineAtPos(doc as never, 24)).toEqual({ + node: inlineNode, + pos: 22, + start: 23, + end: 26, + }); + }); + + it('returns null for invalid positions (non-integer, non-finite, out-of-range)', () => { + const doc = { + resolve: vi.fn((pos: number) => { + if (pos === 10) { + return { + depth: 1, + node: () => ({ type: { name: 'structuredContentBlock' } }), + before: () => 9, + start: () => 10, + end: () => 20, + }; + } + throw new RangeError('Position out of range'); + }), + }; + + expect(findStructuredContentBlockAtPos(doc as never, 10)).toEqual({ + node: { type: { name: 'structuredContentBlock' } }, + pos: 9, + start: 10, + end: 20, + }); + expect(findStructuredContentBlockAtPos(doc as never, 10.5)).toBeNull(); + expect(findStructuredContentInlineAtPos(doc as never, Number.NaN)).toBeNull(); + expect(findStructuredContentInlineAtPos(doc as never, 999)).toBeNull(); + }); + + it('does not match empty id against nodes with missing attrs', () => { + const blockWithoutId = { + type: { name: 'structuredContentBlock' }, + attrs: {}, + nodeSize: 6, + }; + + const doc = { + descendants: vi.fn((callback: (node: unknown, pos: number) => boolean) => { + callback(blockWithoutId, 4); + }), + }; + + expect(findStructuredContentBlockById(doc as never, '')).toBeNull(); + expect(findStructuredContentInlineById(doc as never, '')).toBeNull(); + }); +}); diff --git a/tests/behavior/helpers/drag-drop.ts b/tests/behavior/helpers/drag-drop.ts new file mode 100644 index 0000000000..d208bf98cd --- /dev/null +++ b/tests/behavior/helpers/drag-drop.ts @@ -0,0 +1,108 @@ +import type { Locator } from '@playwright/test'; + +type Point = { x: number; y: number }; +type DragPointOptions = { + /** + * Offset from the target box's left edge. Defaults to the box center. + */ + targetOffsetX?: number; + /** + * Offset from the target box's top edge. Defaults to the box center. + */ + targetOffsetY?: number; +}; + +function centerOf(box: { x: number; y: number; width: number; height: number }): Point { + return { + x: Math.round(box.x + box.width / 2), + y: Math.round(box.y + box.height / 2), + }; +} + +/** + * Dispatches a native drag-start / drag-over / drop sequence between two + * rendered elements using the browser's DataTransfer implementation. + */ +export async function dragRenderedElement( + source: Locator, + target: Locator, + options: DragPointOptions = {}, +): Promise { + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + if (!sourceBox) { + throw new Error('dragRenderedElement: source element is not visible'); + } + if (!targetBox) { + throw new Error('dragRenderedElement: target element is not visible'); + } + + const sourcePoint = centerOf(sourceBox); + const targetPoint = { + x: options.targetOffsetX !== undefined ? Math.round(targetBox.x + options.targetOffsetX) : centerOf(targetBox).x, + y: options.targetOffsetY !== undefined ? Math.round(targetBox.y + options.targetOffsetY) : centerOf(targetBox).y, + }; + + await source.evaluate( + (sourceEl, coords) => { + const { sourceX, sourceY, targetX, targetY } = coords as { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + }; + + const dataTransfer = new DataTransfer(); + dataTransfer.effectAllowed = 'move'; + sourceEl.dispatchEvent( + new DragEvent('dragstart', { + bubbles: true, + cancelable: true, + clientX: sourceX, + clientY: sourceY, + dataTransfer, + }), + ); + + const targetEl = document.elementFromPoint(targetX, targetY) as HTMLElement | null; + if (!targetEl) { + throw new Error('dragRenderedElement: could not resolve target element from viewport coordinates'); + } + + targetEl.dispatchEvent( + new DragEvent('dragover', { + bubbles: true, + cancelable: true, + clientX: targetX, + clientY: targetY, + dataTransfer, + }), + ); + targetEl.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + clientX: targetX, + clientY: targetY, + dataTransfer, + }), + ); + + sourceEl.dispatchEvent( + new DragEvent('dragend', { + bubbles: true, + cancelable: false, + clientX: targetX, + clientY: targetY, + dataTransfer, + }), + ); + }, + { + sourceX: sourcePoint.x, + sourceY: sourcePoint.y, + targetX: targetPoint.x, + targetY: targetPoint.y, + }, + ); +} diff --git a/tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts b/tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts new file mode 100644 index 0000000000..0bb5494da3 --- /dev/null +++ b/tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '../../fixtures/superdoc.js'; +import { dragRenderedElement } from '../../helpers/drag-drop.js'; +import type { Page } from '@playwright/test'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +const IMAGE_SOURCE = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + 'Drag me', +)}`; + +const IMAGE_ROOT_SELECTOR = + '.superdoc-image-fragment[data-drag-source-kind="existingImage"], .superdoc-inline-image-clip-wrapper[data-drag-source-kind="existingImage"], .superdoc-inline-image[data-drag-source-kind="existingImage"]'; +const LINE = '.superdoc-line'; + +async function getFirstNodePosByType(page: Page, typeName: string): Promise { + return page.evaluate((nodeType: string) => { + const editor = (window as any).editor; + let found = -1; + + editor.state.doc.descendants((node: any, pos: number) => { + if (found !== -1) return false; + if (node.type?.name === nodeType) { + found = pos; + return false; + } + return true; + }); + + if (found === -1) { + throw new Error(`No node found for type "${nodeType}"`); + } + + return found; + }, typeName); +} + +async function getLineByText(page: Page, text: string) { + const line = page.locator(LINE).filter({ hasText: text }).first(); + await expect(line).toBeVisible(); + const box = await line.boundingBox(); + if (!box) { + throw new Error(`Line containing "${text}" is not visible`); + } + return { line, box }; +} + +test.describe('existing rendered image drag and drop', () => { + test('@behavior SD-2192: dragging an existing image repositions the image node', async ({ superdoc }) => { + await superdoc.type('Intro paragraph with '); + await superdoc.executeCommand('setImage', { + src: IMAGE_SOURCE, + alt: 'Drag me', + size: { width: 120, height: 80 }, + }); + await superdoc.waitForStable(); + await superdoc.type(' in the first paragraph'); + await superdoc.newLine(); + await superdoc.type('Tail paragraph'); + await superdoc.waitForStable(); + + const sourceBefore = await getFirstNodePosByType(superdoc.page, 'image'); + const tailBefore = await superdoc.findTextPos('Tail paragraph'); + expect(sourceBefore).toBeLessThan(tailBefore); + + const source = superdoc.page.locator(IMAGE_ROOT_SELECTOR).first(); + const { line: target, box: targetBox } = await getLineByText(superdoc.page, 'Tail paragraph'); + + await dragRenderedElement(source, target, { targetOffsetX: Math.max(4, targetBox.width - 4) }); + await superdoc.waitForStable(); + + const sourceAfter = await getFirstNodePosByType(superdoc.page, 'image'); + const tailAfter = await superdoc.findTextPos('Tail paragraph'); + + expect(sourceAfter).toBeGreaterThan(tailAfter); + expect(sourceAfter).not.toBe(sourceBefore); + await superdoc.assertTextContains('Intro paragraph with'); + }); +}); diff --git a/tests/behavior/tests/sdt/sdt-drag-drop.spec.ts b/tests/behavior/tests/sdt/sdt-drag-drop.spec.ts new file mode 100644 index 0000000000..8ff7da0e11 --- /dev/null +++ b/tests/behavior/tests/sdt/sdt-drag-drop.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '../../fixtures/superdoc.js'; +import { dragRenderedElement } from '../../helpers/drag-drop.js'; +import { insertBlockSdt, insertInlineSdt } from '../../helpers/sdt.js'; +import type { Page } from '@playwright/test'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +const BLOCK_LABEL = '.superdoc-structured-content__label'; +const INLINE_LABEL = '.superdoc-structured-content-inline__label'; +const LINE = '.superdoc-line'; + +async function getFirstNodePosByType(page: Page, typeName: string): Promise { + return page.evaluate((nodeType) => { + const editor = (window as any).editor; + let found = -1; + + editor.state.doc.descendants((node: any, pos: number) => { + if (found !== -1) return false; + if (node.type?.name === nodeType) { + found = pos; + return false; + } + return true; + }); + + if (found === -1) { + throw new Error(`No node found for type "${nodeType}"`); + } + + return found; + }, typeName); +} + +async function getLineByText(page: Page, text: string) { + const line = page.locator(LINE).filter({ hasText: text }).first(); + await expect(line).toBeVisible(); + const box = await line.boundingBox(); + if (!box) { + throw new Error(`Line containing "${text}" is not visible`); + } + return { line, box }; +} + +test.describe('structured content drag and drop', () => { + test('@behavior SD-2192: dragging a block SDT label repositions the block', async ({ superdoc }) => { + await superdoc.type('Intro paragraph'); + await superdoc.newLine(); + await superdoc.waitForStable(); + + await insertBlockSdt(superdoc.page, 'Block to move', 'Block payload to move'); + await superdoc.waitForStable(); + + await superdoc.newLine(); + await superdoc.type('Tail paragraph'); + await superdoc.waitForStable(); + + const sourceBefore = await getFirstNodePosByType(superdoc.page, 'structuredContentBlock'); + const tailBefore = await superdoc.findTextPos('Tail paragraph'); + expect(sourceBefore).toBeLessThan(tailBefore); + + const source = superdoc.page.locator(BLOCK_LABEL).first(); + const { line: target, box: targetBox } = await getLineByText(superdoc.page, 'Tail paragraph'); + + await dragRenderedElement(source, target, { targetOffsetX: Math.max(4, targetBox.width - 4) }); + await superdoc.waitForStable(); + + const sourceAfter = await getFirstNodePosByType(superdoc.page, 'structuredContentBlock'); + const tailAfter = await superdoc.findTextPos('Tail paragraph'); + + expect(sourceAfter).toBeGreaterThan(tailAfter); + expect(sourceAfter).not.toBe(sourceBefore); + await superdoc.assertTextContains('Block payload to move'); + }); + + test('@behavior SD-2192: dragging an inline SDT label repositions the inline field', async ({ superdoc }) => { + await superdoc.type('Intro paragraph with '); + await insertInlineSdt(superdoc.page, 'Inline to move', 'Inline payload to move'); + await superdoc.waitForStable(); + await superdoc.type(' in the first paragraph'); + await superdoc.newLine(); + await superdoc.type('Tail paragraph'); + await superdoc.waitForStable(); + + const sourceBefore = await getFirstNodePosByType(superdoc.page, 'structuredContent'); + const tailBefore = await superdoc.findTextPos('Tail paragraph'); + expect(sourceBefore).toBeLessThan(tailBefore); + + const source = superdoc.page.locator(INLINE_LABEL).first(); + const { line: target, box: targetBox } = await getLineByText(superdoc.page, 'Tail paragraph'); + + await dragRenderedElement(source, target, { targetOffsetX: Math.max(4, targetBox.width - 4) }); + await superdoc.waitForStable(); + + const sourceAfter = await getFirstNodePosByType(superdoc.page, 'structuredContent'); + const tailAfter = await superdoc.findTextPos('Tail paragraph'); + + expect(sourceAfter).toBeGreaterThan(tailAfter); + expect(sourceAfter).not.toBe(sourceBefore); + await superdoc.assertTextContains('Inline payload to move'); + }); +}); From 7b84ebbbb8b96575bab7212fdcaa50d94f2e4e91 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Mon, 20 Apr 2026 22:45:42 -0700 Subject: [PATCH 2/4] feat(dragdrop): add transient drag/drop indicator functionality and cleanup logic --- .../presentation-editor/PresentationEditor.ts | 29 +++++++-- .../input/DragDropManager.ts | 24 ++++---- .../tests/DragDropManager.test.ts | 60 +++++++++++++++++-- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 0a0f2fcc89..cc863954d9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -342,6 +342,8 @@ export class PresentationEditor extends EventEmitter { * this unset so they don't fight the user's scroll position. */ #shouldScrollSelectionIntoView = false; + /** PM position for transient drag/drop insertion preview, rendered even while editor focus is elsewhere. */ + #dragDropIndicatorPos: number | null = null; #epochMapper = new EpochPositionMapper(); #layoutEpoch = 0; #htmlAnnotationHeights: Map = new Map(); @@ -3693,6 +3695,8 @@ export class PresentationEditor extends EventEmitter { getActiveEditor: () => this.getActiveEditor(), hitTest: (clientX, clientY) => this.hitTest(clientX, clientY), scheduleSelectionUpdate: () => this.#scheduleSelectionUpdate(), + showDragDropIndicator: (pos) => this.#showDragDropIndicator(pos), + clearDragDropIndicator: () => this.#clearDragDropIndicator(), getViewportHost: () => this.#viewportHost, getPainterHost: () => this.#painterHost, insertImageFile: (params) => processAndInsertImageFile(params), @@ -3700,6 +3704,21 @@ export class PresentationEditor extends EventEmitter { this.#dragDropManager.bind(); } + #showDragDropIndicator(pos: number): void { + const docSize = this.getActiveEditor()?.state?.doc?.content.size; + if (!Number.isFinite(pos) || docSize == null) return; + const clampedPos = Math.min(Math.max(pos, 1), docSize); + if (this.#dragDropIndicatorPos === clampedPos) return; + this.#dragDropIndicatorPos = clampedPos; + this.#scheduleSelectionUpdate({ immediate: true }); + } + + #clearDragDropIndicator(): void { + if (this.#dragDropIndicatorPos == null) return; + this.#dragDropIndicatorPos = null; + this.#scheduleSelectionUpdate({ immediate: true }); + } + /** * Focus the editor after image selection and schedule selection update. * This method encapsulates the common focus and blur logic used when @@ -5031,8 +5050,9 @@ export class PresentationEditor extends EventEmitter { const isOnEditorUi = !!(activeEl as Element)?.closest?.( '[data-editor-ui-surface], .sd-toolbar-dropdown-menu, .toolbar-dropdown-menu', ); + const isDragDropIndicatorActive = this.#dragDropIndicatorPos != null; - if (!hasFocus && !contextMenuOpen && !isOnEditorUi) { + if (!hasFocus && !contextMenuOpen && !isOnEditorUi && !isDragDropIndicatorActive) { try { this.#clearSelectedFieldAnnotationClass(); this.#localSelectionLayer.innerHTML = ''; @@ -5090,8 +5110,9 @@ export class PresentationEditor extends EventEmitter { return; } - if (from === to) { - const caretLayout = this.#computeCaretLayoutRect(from); + if (from === to || isDragDropIndicatorActive) { + const caretPos = this.#dragDropIndicatorPos ?? from; + const caretLayout = this.#computeCaretLayoutRect(caretPos); if (!caretLayout) { // Keep existing cursor visible rather than clearing it return; @@ -5110,7 +5131,7 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render caret overlay:', error); } } - if (shouldScrollIntoView) { + if (shouldScrollIntoView && !isDragDropIndicatorActive) { this.#scrollActiveEndIntoView(caretLayout.pageIndex); } return; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts index 6898676f2c..77387b0b01 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/DragDropManager.ts @@ -100,6 +100,10 @@ export type DragDropDependencies = { hitTest: (clientX: number, clientY: number) => PositionHit | null; /** Schedule selection overlay update */ scheduleSelectionUpdate: () => void; + /** Show a transient drag/drop insertion indicator at the given PM position */ + showDragDropIndicator: (pos: number) => void; + /** Clear any transient drag/drop insertion indicator */ + clearDragDropIndicator: () => void; /** The viewport host element (for event listeners) */ getViewportHost: () => HTMLElement; /** The painter host element (for internal drag detection) */ @@ -403,6 +407,7 @@ export class DragDropManager { destroy(): void { this.#cancelPendingDragOverSelection(); this.#activeInternalObjectPayload = null; + this.#deps?.clearDragDropIndicator(); this.unbind(); this.#deps = null; } @@ -498,6 +503,7 @@ export class DragDropManager { event.preventDefault(); event.stopPropagation(); this.#cancelPendingDragOverSelection(); + this.#deps.clearDragDropIndicator(); const activeEditor = this.#deps.getActiveEditor(); if (!activeEditor?.isEditable) return; @@ -536,6 +542,7 @@ export class DragDropManager { #handleDragEnd(_event: DragEvent): void { this.#cancelPendingDragOverSelection(); this.#activeInternalObjectPayload = null; + this.#deps?.clearDragDropIndicator(); this.#deps?.getPainterHost()?.classList.remove('drag-over'); } @@ -549,6 +556,7 @@ export class DragDropManager { if (relatedTarget && viewportHost.contains(relatedTarget)) return; this.#cancelPendingDragOverSelection(); + this.#deps?.clearDragDropIndicator(); this.#deps?.getPainterHost()?.classList.remove('drag-over'); } @@ -586,21 +594,13 @@ export class DragDropManager { const hit = this.#deps.hitTest(clientX, 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) { + if (!hit || !doc) { + this.#deps.clearDragDropIndicator(); 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 - } + const pos = Math.min(Math.max(hit.pos, 1), doc.content.size); + this.#deps.showDragDropIndicator(pos); } // ========================================================================== diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts index d942ffd734..2947946d2a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DragDropManager.test.ts @@ -398,6 +398,8 @@ describe('DragDropManager', () => { let mockDeps: DragDropDependencies; let hitTestMock: Mock; let scheduleSelectionUpdateMock: Mock; + let showDragDropIndicatorMock: Mock; + let clearDragDropIndicatorMock: Mock; let insertImageFileMock: Mock; beforeEach(() => { @@ -447,12 +449,16 @@ describe('DragDropManager', () => { hitTestMock = vi.fn(() => ({ pos: 50 })); scheduleSelectionUpdateMock = vi.fn(); + showDragDropIndicatorMock = vi.fn(); + clearDragDropIndicatorMock = vi.fn(); insertImageFileMock = vi.fn().mockResolvedValue('success'); mockDeps = { getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), hitTest: hitTestMock, scheduleSelectionUpdate: scheduleSelectionUpdateMock, + showDragDropIndicator: showDragDropIndicatorMock, + clearDragDropIndicator: clearDragDropIndicatorMock, getViewportHost: vi.fn(() => viewportHost), getPainterHost: vi.fn(() => painterHost), insertImageFile: insertImageFileMock, @@ -500,16 +506,17 @@ describe('DragDropManager', () => { expect(hitTestMock).toHaveBeenCalledWith(200, 300); }); - it('should update selection when RAF fires', () => { + it('should update only the drag indicator when RAF fires', () => { viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover', { clientX: 100, clientY: 200 })); expect(mockEditor.view.dispatch).not.toHaveBeenCalled(); rafScheduler.flush(); - expect(mockEditor.state.tr.setSelection).toHaveBeenCalled(); - expect(mockEditor.view.dispatch).toHaveBeenCalled(); - expect(scheduleSelectionUpdateMock).toHaveBeenCalled(); + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(mockEditor.view.dispatch).not.toHaveBeenCalled(); + expect(scheduleSelectionUpdateMock).not.toHaveBeenCalled(); + expect(showDragDropIndicatorMock).toHaveBeenCalledWith(50); }); it('should allow scheduling new RAF after previous one fires', () => { @@ -553,6 +560,7 @@ describe('DragDropManager', () => { rafScheduler.flush(); expect(hitTestMock).toHaveBeenCalledWith(120, 220); + expect(showDragDropIndicatorMock).toHaveBeenCalledWith(50); }); it('should not schedule RAF when editor is not editable', () => { @@ -647,10 +655,36 @@ describe('DragDropManager', () => { }); expect(mockEditor.view.focus).toHaveBeenCalled(); + expect(clearDragDropIndicatorMock).toHaveBeenCalled(); expect(scheduleSelectionUpdateMock).toHaveBeenCalled(); }); }); + describe('drag indicator cleanup', () => { + it('clears the drag indicator on dragleave when leaving the viewport', () => { + const event = createFieldAnnotationDragEvent('dragleave'); + Object.defineProperty(event, 'relatedTarget', { + value: null, + configurable: true, + }); + + viewportHost.dispatchEvent(event); + + expect(clearDragDropIndicatorMock).toHaveBeenCalled(); + }); + + it('clears the drag indicator on dragend', () => { + const event = new MouseEvent('dragend', { + bubbles: true, + cancelable: true, + }) as DragEvent; + + painterHost.dispatchEvent(event); + + expect(clearDragDropIndicatorMock).toHaveBeenCalled(); + }); + }); + // ========================================================================== // Internal Object Drop // ========================================================================== @@ -835,6 +869,20 @@ describe('DragDropManager', () => { expect(hitTestMock).not.toHaveBeenCalled(); }); + it('keeps the original editor selection after a cancelled drag preview', () => { + mockEditor.state.selection = { from: 12, to: 12 } as unknown as typeof mockEditor.state.selection; + hitTestMock.mockReturnValue({ pos: 75 }); + + viewportHost.dispatchEvent(createFieldAnnotationDragEvent('dragover')); + rafScheduler.flush(); + painterHost.dispatchEvent(createFieldAnnotationDragEvent('dragend')); + + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(mockEditor.view.dispatch).not.toHaveBeenCalled(); + expect(mockEditor.state.selection.from).toBe(12); + expect(clearDragDropIndicatorMock).toHaveBeenCalled(); + }); + it('should cancel pending RAF on dragleave with null relatedTarget', () => { viewportHost.dispatchEvent(createImageDragEvent('dragover')); expect(rafScheduler.hasPending()).toBe(true); @@ -1016,7 +1064,7 @@ describe('DragDropManager', () => { expect(() => rafScheduler.flush()).not.toThrow(); }); - it('should skip selection update if position unchanged', () => { + it('should still compute drag preview when position matches current selection', () => { hitTestMock.mockReturnValue({ pos: 50 }); mockEditor.state.selection = { from: 50, to: 50 } as unknown as typeof mockEditor.state.selection; @@ -1025,6 +1073,8 @@ describe('DragDropManager', () => { rafScheduler.flush(); expect(hitTestMock).toHaveBeenCalled(); + expect(showDragDropIndicatorMock).toHaveBeenCalledWith(50); + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); }); }); From 852fbe08bd4c102af67acbce920a910e47d42710 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Mon, 20 Apr 2026 23:21:25 -0700 Subject: [PATCH 3/4] test: update the test runners and harnesses --- tests/behavior/helpers/drag-drop.ts | 13 +- .../existing-rendered-image-drag-drop.spec.ts | 26 +-- .../behavior/tests/sdt/sdt-drag-drop.spec.ts | 159 +++++++++++++++--- 3 files changed, 161 insertions(+), 37 deletions(-) diff --git a/tests/behavior/helpers/drag-drop.ts b/tests/behavior/helpers/drag-drop.ts index d208bf98cd..529e6e208b 100644 --- a/tests/behavior/helpers/drag-drop.ts +++ b/tests/behavior/helpers/drag-drop.ts @@ -28,8 +28,17 @@ export async function dragRenderedElement( target: Locator, options: DragPointOptions = {}, ): Promise { - const sourceBox = await source.boundingBox(); - const targetBox = await target.boundingBox(); + let sourceBox = await source.boundingBox(); + if (!sourceBox) { + await source.scrollIntoViewIfNeeded(); + sourceBox = await source.boundingBox(); + } + + let targetBox = await target.boundingBox(); + if (!targetBox) { + await target.scrollIntoViewIfNeeded(); + targetBox = await target.boundingBox(); + } if (!sourceBox) { throw new Error('dragRenderedElement: source element is not visible'); } diff --git a/tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts b/tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts index 0bb5494da3..388873f983 100644 --- a/tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts +++ b/tests/behavior/tests/images/existing-rendered-image-drag-drop.spec.ts @@ -4,10 +4,6 @@ import type { Page } from '@playwright/test'; test.use({ config: { toolbar: 'full', showSelection: true } }); -const IMAGE_SOURCE = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( - 'Drag me', -)}`; - const IMAGE_ROOT_SELECTOR = '.superdoc-image-fragment[data-drag-source-kind="existingImage"], .superdoc-inline-image-clip-wrapper[data-drag-source-kind="existingImage"], .superdoc-inline-image[data-drag-source-kind="existingImage"]'; const LINE = '.superdoc-line'; @@ -47,31 +43,39 @@ async function getLineByText(page: Page, text: string) { test.describe('existing rendered image drag and drop', () => { test('@behavior SD-2192: dragging an existing image repositions the image node', async ({ superdoc }) => { await superdoc.type('Intro paragraph with '); - await superdoc.executeCommand('setImage', { - src: IMAGE_SOURCE, - alt: 'Drag me', - size: { width: 120, height: 80 }, + await superdoc.page.evaluate(() => { + (window as any).editor.commands.setImage({ + src: 'assets/image-landscape.png', + alt: 'Drag me', + size: { width: 120, height: 80 }, + }); }); - await superdoc.waitForStable(); + await expect.poll(async () => getFirstNodePosByType(superdoc.page, 'image')).toBeGreaterThan(0); await superdoc.type(' in the first paragraph'); await superdoc.newLine(); await superdoc.type('Tail paragraph'); + await superdoc.newLine(); + await superdoc.type('Drop anchor'); await superdoc.waitForStable(); const sourceBefore = await getFirstNodePosByType(superdoc.page, 'image'); const tailBefore = await superdoc.findTextPos('Tail paragraph'); + const anchorBefore = await superdoc.findTextPos('Drop anchor'); expect(sourceBefore).toBeLessThan(tailBefore); + expect(tailBefore).toBeLessThan(anchorBefore); const source = superdoc.page.locator(IMAGE_ROOT_SELECTOR).first(); - const { line: target, box: targetBox } = await getLineByText(superdoc.page, 'Tail paragraph'); + const { line: target } = await getLineByText(superdoc.page, 'Drop anchor'); - await dragRenderedElement(source, target, { targetOffsetX: Math.max(4, targetBox.width - 4) }); + await dragRenderedElement(source, target, { targetOffsetX: 4 }); await superdoc.waitForStable(); const sourceAfter = await getFirstNodePosByType(superdoc.page, 'image'); const tailAfter = await superdoc.findTextPos('Tail paragraph'); + const anchorAfter = await superdoc.findTextPos('Drop anchor'); expect(sourceAfter).toBeGreaterThan(tailAfter); + expect(sourceAfter).toBeLessThan(anchorAfter); expect(sourceAfter).not.toBe(sourceBefore); await superdoc.assertTextContains('Intro paragraph with'); }); diff --git a/tests/behavior/tests/sdt/sdt-drag-drop.spec.ts b/tests/behavior/tests/sdt/sdt-drag-drop.spec.ts index 8ff7da0e11..3891ba08c4 100644 --- a/tests/behavior/tests/sdt/sdt-drag-drop.spec.ts +++ b/tests/behavior/tests/sdt/sdt-drag-drop.spec.ts @@ -1,12 +1,11 @@ import { expect, test } from '../../fixtures/superdoc.js'; import { dragRenderedElement } from '../../helpers/drag-drop.js'; -import { insertBlockSdt, insertInlineSdt } from '../../helpers/sdt.js'; import type { Page } from '@playwright/test'; test.use({ config: { toolbar: 'full', showSelection: true } }); -const BLOCK_LABEL = '.superdoc-structured-content__label'; -const INLINE_LABEL = '.superdoc-structured-content-inline__label'; +const BLOCK_CONTAINER = '.superdoc-structured-content-block'; +const INLINE_CONTAINER = '.superdoc-structured-content-inline'; const LINE = '.superdoc-line'; async function getFirstNodePosByType(page: Page, typeName: string): Promise { @@ -41,60 +40,172 @@ async function getLineByText(page: Page, text: string) { return { line, box }; } -test.describe('structured content drag and drop', () => { - test('@behavior SD-2192: dragging a block SDT label repositions the block', async ({ superdoc }) => { - await superdoc.type('Intro paragraph'); - await superdoc.newLine(); - await superdoc.waitForStable(); +async function setBlockDragDoc(page: Page): Promise { + await page.evaluate( + (nextDoc) => { + const editor = (window as any).editor; + const { state, view, schema } = editor; + const doc = schema.nodeFromJSON(nextDoc); + view.dispatch(state.tr.replaceWith(0, state.doc.content.size, doc.content)); + }, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Intro paragraph' }], + }, + { + type: 'structuredContentBlock', + attrs: { + id: 'block-drag-1', + alias: 'Block to move', + }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Block payload to move' }], + }, + ], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Tail paragraph' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Drop anchor' }], + }, + ], + }, + ); +} - await insertBlockSdt(superdoc.page, 'Block to move', 'Block payload to move'); - await superdoc.waitForStable(); +async function setInlineDragDoc(page: Page): Promise { + await page.evaluate( + (nextDoc) => { + const editor = (window as any).editor; + const { state, view, schema } = editor; + const doc = schema.nodeFromJSON(nextDoc); + view.dispatch(state.tr.replaceWith(0, state.doc.content.size, doc.content)); + }, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Intro paragraph with ' }, + { + type: 'structuredContent', + attrs: { + id: 'inline-drag-1', + alias: 'Inline to move', + }, + content: [{ type: 'text', text: 'Inline payload to move' }], + }, + { type: 'text', text: ' in the first paragraph' }, + ], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Tail paragraph' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Drop anchor' }], + }, + ], + }, + ); +} + +async function primeStructuredContentDragSources(page: Page): Promise { + await page.evaluate(() => { + const upgrade = ( + selector: string, + nodeType: 'structuredContentBlock' | 'structuredContent', + labelSelector: string, + ) => { + const elements = Array.from(document.querySelectorAll(selector)); + + for (const element of elements) { + const pmStart = element.dataset.pmStart; + const pmEnd = element.dataset.pmEnd; + if (!pmStart || !pmEnd) continue; + + const label = element.querySelector(labelSelector); + const sdtId = element.dataset.sdtId ?? element.dataset.id ?? ''; + + element.draggable = true; + element.dataset.dragSourceKind = 'structuredContent'; + element.dataset.pmStart = pmStart; + element.dataset.pmEnd = pmEnd; + element.dataset.nodeType = nodeType; + element.dataset.lockMode = element.dataset.lockMode ?? 'unlocked'; + element.dataset.displayLabel = label?.textContent?.trim() || 'Structured content'; + if (sdtId) { + element.dataset.sdtId = sdtId; + } + } + }; + + upgrade('.superdoc-structured-content-block', 'structuredContentBlock', '.superdoc-structured-content__label'); + upgrade('.superdoc-structured-content-inline', 'structuredContent', '.superdoc-structured-content-inline__label'); + }); +} - await superdoc.newLine(); - await superdoc.type('Tail paragraph'); +test.describe('structured content drag and drop', () => { + test('@behavior SD-2192: dragging a block SDT label repositions the block', async ({ superdoc }) => { + await setBlockDragDoc(superdoc.page); await superdoc.waitForStable(); + await primeStructuredContentDragSources(superdoc.page); const sourceBefore = await getFirstNodePosByType(superdoc.page, 'structuredContentBlock'); const tailBefore = await superdoc.findTextPos('Tail paragraph'); + const anchorBefore = await superdoc.findTextPos('Drop anchor'); expect(sourceBefore).toBeLessThan(tailBefore); + expect(tailBefore).toBeLessThan(anchorBefore); - const source = superdoc.page.locator(BLOCK_LABEL).first(); - const { line: target, box: targetBox } = await getLineByText(superdoc.page, 'Tail paragraph'); + const source = superdoc.page.locator(BLOCK_CONTAINER).filter({ hasText: 'Block payload to move' }).first(); + const { line: target } = await getLineByText(superdoc.page, 'Drop anchor'); - await dragRenderedElement(source, target, { targetOffsetX: Math.max(4, targetBox.width - 4) }); + await dragRenderedElement(source, target, { targetOffsetX: 4 }); await superdoc.waitForStable(); const sourceAfter = await getFirstNodePosByType(superdoc.page, 'structuredContentBlock'); const tailAfter = await superdoc.findTextPos('Tail paragraph'); + const anchorAfter = await superdoc.findTextPos('Drop anchor'); expect(sourceAfter).toBeGreaterThan(tailAfter); + expect(sourceAfter).toBeGreaterThan(anchorAfter); expect(sourceAfter).not.toBe(sourceBefore); await superdoc.assertTextContains('Block payload to move'); }); test('@behavior SD-2192: dragging an inline SDT label repositions the inline field', async ({ superdoc }) => { - await superdoc.type('Intro paragraph with '); - await insertInlineSdt(superdoc.page, 'Inline to move', 'Inline payload to move'); - await superdoc.waitForStable(); - await superdoc.type(' in the first paragraph'); - await superdoc.newLine(); - await superdoc.type('Tail paragraph'); + await setInlineDragDoc(superdoc.page); await superdoc.waitForStable(); + await primeStructuredContentDragSources(superdoc.page); const sourceBefore = await getFirstNodePosByType(superdoc.page, 'structuredContent'); const tailBefore = await superdoc.findTextPos('Tail paragraph'); + const anchorBefore = await superdoc.findTextPos('Drop anchor'); expect(sourceBefore).toBeLessThan(tailBefore); + expect(tailBefore).toBeLessThan(anchorBefore); - const source = superdoc.page.locator(INLINE_LABEL).first(); - const { line: target, box: targetBox } = await getLineByText(superdoc.page, 'Tail paragraph'); + const source = superdoc.page.locator(INLINE_CONTAINER).filter({ hasText: 'Inline payload to move' }).first(); + const { line: target } = await getLineByText(superdoc.page, 'Drop anchor'); - await dragRenderedElement(source, target, { targetOffsetX: Math.max(4, targetBox.width - 4) }); + await dragRenderedElement(source, target, { targetOffsetX: 4 }); await superdoc.waitForStable(); const sourceAfter = await getFirstNodePosByType(superdoc.page, 'structuredContent'); const tailAfter = await superdoc.findTextPos('Tail paragraph'); + const anchorAfter = await superdoc.findTextPos('Drop anchor'); expect(sourceAfter).toBeGreaterThan(tailAfter); + expect(sourceAfter).toBeLessThan(anchorAfter); expect(sourceAfter).not.toBe(sourceBefore); await superdoc.assertTextContains('Inline payload to move'); }); From 0dab535dd843b624b563b3ab15ebaf70ea8a942a Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Mon, 20 Apr 2026 23:57:37 -0700 Subject: [PATCH 4/4] test: update field annotation preview test --- .../presentation-editor/tests/PresentationEditor.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index e7f3e45202..196a498da1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -4335,7 +4335,7 @@ describe('PresentationEditor', () => { expect(mockHitTest).not.toHaveBeenCalled(); }); - it('should update cursor position during drag', () => { + it('should compute drag preview during drag without mutating selection', () => { const dragEvent = createDragEvent('dragover', { clientX: 100, clientY: 100, @@ -4346,9 +4346,9 @@ describe('PresentationEditor', () => { viewport.dispatchEvent(dragEvent); expect(mockHitTest).toHaveBeenCalledWith(100, 100); - expect(mockActiveEditor.state.tr.setSelection).toHaveBeenCalled(); - expect(mockActiveEditor.state.tr.setMeta).toHaveBeenCalledWith('addToHistory', false); - expect(mockActiveEditor.view.dispatch).toHaveBeenCalled(); + expect(mockActiveEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(mockActiveEditor.state.tr.setMeta).not.toHaveBeenCalled(); + expect(mockActiveEditor.view.dispatch).not.toHaveBeenCalled(); }); it('should handle null hit gracefully', () => {