Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -337,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<string, number> = new Map();
Expand Down Expand Up @@ -494,6 +501,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);
Expand Down Expand Up @@ -3687,13 +3695,30 @@ 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),
});
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
Expand Down Expand Up @@ -4688,6 +4713,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) {
Expand All @@ -4696,24 +4722,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) {
Expand All @@ -4730,7 +4759,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];
Expand Down Expand Up @@ -4903,31 +4932,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) {
Expand Down Expand Up @@ -5032,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 = '';
Expand Down Expand Up @@ -5091,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;
Expand All @@ -5111,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
ensureEditorNativeSelectionStyles,
ensureEditorFieldAnnotationInteractionStyles,
ensureEditorMovableObjectInteractionStyles,
_resetEditorStyleFlags,
} from './EditorStyleInjector.js';

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();
});

Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -121,4 +156,5 @@ export function ensureEditorFieldAnnotationInteractionStyles(doc: Document | nul
export function _resetEditorStyleFlags(): void {
nativeSelectionStylesInjected = false;
fieldAnnotationInteractionStylesInjected = false;
movableObjectInteractionStylesInjected = false;
}
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="superdoc-image-fragment" data-pm-start="10" data-pm-end="16" data-block-id="image-block">
<img data-image-metadata='{"width":100}' alt="Block image" />
</div>
<span class="superdoc-inline-image-clip-wrapper" data-pm-start="20" data-pm-end="24">
<img class="superdoc-inline-image" data-image-metadata='{"width":50}' alt="Inline image" />
</span>
<img class="superdoc-inline-image" data-pm-start="30" data-pm-end="34" data-image-metadata='{"width":60}' alt="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 = `
<div class="superdoc-image-fragment">
<img data-image-metadata='{"width":100}' alt="Missing position" />
</div>
`;

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 = `
<div class="superdoc-image-fragment" data-pm-start="10" data-pm-end="16" data-block-id="image-block">
<img data-image-metadata='{"width":100}' alt="Block image" />
</div>
`;

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();
});
});
Loading
Loading