Skip to content
Merged
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 @@ -620,11 +620,12 @@ export class DotCMSEditorComponent implements OnInit, OnDestroy, ControlValueAcc
// Guard: skip when value is empty to avoid overriding CVA-set content on init;
// skip when unchanged so two-way [value] + (valueChange) does not reset the cursor.
// Also tracks `editor()` so the effect re-fires once the slow-path editor mounts.
// Skip while dragging — setContent mid-drag can turn a move into a duplicate (#36976).
effect(() => {
const v = this.value();
if (!v) return;
const ed = this.editor();
if (!ed) return;
if (!ed || ed.view.dragging) return;
const parsed = normalizeEditorContent(v);
if (editorContentMatchesParsed(ed, parsed)) return;
ed.commands.setContent(
Expand Down Expand Up @@ -746,6 +747,7 @@ export class DotCMSEditorComponent implements OnInit, OnDestroy, ControlValueAcc
this.pendingValue = content ?? '';
return;
}
if (ed.view.dragging) return;
const parsed = normalizeEditorContent(content);
if (editorContentMatchesParsed(ed, parsed)) return;
ed.commands.setContent(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Editor, Node } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state';
import StarterKit from '@tiptap/starter-kit';

import {
createBlockGutterDragHandle,
getViewDragging,
patchAtomDragToNodeSelection,
setViewDragging
} from './block-gutter.extension';

/** Minimal atom block — no Angular node view — for drag-move unit tests. */
const TestAtom = Node.create({
name: 'testAtom',
group: 'block',
atom: true,
addAttributes() {
return { id: { default: null } };
},
parseHTML() {
return [{ tag: 'div[data-test-atom]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-test-atom': '', ...HTMLAttributes }];
}
});

function buildAtomEditor(): Editor {
return new Editor({
extensions: [StarterKit, createBlockGutterDragHandle('Add block'), TestAtom],
content: {
type: 'doc',
content: [
{ type: 'testAtom', attrs: { id: 'aaa' } },
{ type: 'testAtom', attrs: { id: 'bbb' } },
{ type: 'testAtom', attrs: { id: 'ccc' } }
]
}
});
}

describe('block gutter atom drag move fix (#36976)', () => {
let editor: Editor;

afterEach(() => {
editor?.destroy();
});

it('registers the blockGutter extension (drag handle + atom move fix)', () => {
editor = buildAtomEditor();
expect(editor.extensionManager.extensions.some((ext) => ext.name === 'blockGutter')).toBe(
true
);
expect(editor.extensionManager.extensions.some((ext) => ext.name === 'dragHandle')).toBe(
true
);
});

it('patchAtomDragToNodeSelection rewrites view.dragging to a NodeSelection for atoms', () => {
editor = buildAtomEditor();
let thirdPos = -1;
editor.state.doc.forEach((child, offset) => {
if (child.attrs['id'] === 'ccc') thirdPos = offset;
});
expect(thirdPos).toBeGreaterThanOrEqual(0);

const node = editor.state.doc.nodeAt(thirdPos);
expect(node?.type.name).toBe('testAtom');
expect(node?.isAtom).toBe(true);

setViewDragging(editor.view, {
slice: editor.state.doc.slice(thirdPos, thirdPos + node!.nodeSize),
move: true,
node: undefined
});

expect(patchAtomDragToNodeSelection(editor.view, thirdPos)).toBe(true);

const dragging = getViewDragging(editor.view);
expect(dragging?.move).toBe(true);
expect(dragging?.node).toBeInstanceOf(NodeSelection);
expect(dragging?.node?.from).toBe(thirdPos);
expect(editor.state.selection).toBeInstanceOf(NodeSelection);
expect(editor.state.selection.from).toBe(thirdPos);
});

it('patchAtomDragToNodeSelection is a no-op for non-atom blocks', () => {
editor = new Editor({
extensions: [StarterKit, createBlockGutterDragHandle('Add block')],
content: '<p>Hello</p><p>World</p>'
});
setViewDragging(editor.view, {
slice: editor.state.doc.slice(0, 7),
move: true,
node: undefined
});
expect(patchAtomDragToNodeSelection(editor.view, 0)).toBe(false);
expect(getViewDragging(editor.view)?.node).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import { autoUpdate, computePosition, offset, shift } from '@floating-ui/dom';

import type { Editor } from '@tiptap/core';
import { Extension, type Editor } from '@tiptap/core';
import { DragHandle, defaultComputePositionConfig } from '@tiptap/extension-drag-handle';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import type { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model';
import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';

/**
* Runtime shape of `EditorView.dragging`. Public typings only declare `{ slice, move }`, but
* ProseMirror also carries an optional `node` (`NodeSelection`) used on drop to delete the source.
*/
type ViewDragging = {
slice: Slice;
move: boolean;
node?: NodeSelection | null;
};

export function getViewDragging(view: EditorView): ViewDragging | null {
return (view.dragging as ViewDragging | null) ?? null;
}

export function setViewDragging(view: EditorView, dragging: ViewDragging): void {
view.dragging = dragging;
}

/**
* Shared mutable state for the block gutter (drag grip + add button), updated on each hover.
Expand Down Expand Up @@ -316,6 +336,32 @@ function createGutterAutoPositioner(state: GutterState): {
return { schedule, tearDown };
}

/**
* TipTap's drag-handle sets `view.dragging` without `dragging.node`. For atom NodeViews
* (e.g. `dotContent`), ProseMirror's `deleteSelection()` path can miss the source delete on
* drop and leave a duplicate (#36976). A `NodeSelection` makes the drop use `node.replace(tr)`.
*/
export function patchAtomDragToNodeSelection(view: EditorView, pos: number): boolean {
if (pos < 0) return false;
const node = view.state.doc.nodeAt(pos);
if (!node?.isAtom || !getViewDragging(view)) return false;

const nodeSelection = NodeSelection.create(view.state.doc, pos);
setViewDragging(view, {
slice: nodeSelection.content(),
move: true,
node: nodeSelection
});

if (!(view.state.selection instanceof NodeSelection) || view.state.selection.from !== pos) {
view.dispatch(view.state.tr.setSelection(nodeSelection));
}

return true;
}

const ATOM_DRAG_MOVE_FIX_KEY = new PluginKey('blockGutterAtomDragMoveFix');

/**
* Configures TipTap’s {@link DragHandle} with a two-part gutter: draggable grip + “+” button.
*
Expand All @@ -325,15 +371,18 @@ function createGutterAutoPositioner(state: GutterState): {
* - Floating UI `shift` keeps the gutter on-screen; `autoUpdate` re-runs positioning on scroll
* (TipTap’s plugin only recomputes when the hovered *node* changes, and `document` scroll misses
* inner scroll containers because scroll events do not bubble).
* - Atom NodeView reorder fix (#36976): after TipTap’s dragHandler runs, rewrite `view.dragging`
* to a `NodeSelection` so the drop’s source-delete half cannot be skipped.
*
* @param addBlockAriaLabel - Pre-translated aria-label for the “+” button.
* @returns A configured `DragHandle` extension ready for `Editor` extensions array.
* @returns An extension that registers the drag handle plus the atom-move fix plugin.
*/
export function createBlockGutterDragHandle(addBlockAriaLabel: string) {
const state: GutterState = { editor: null, pos: -1, nodeSize: 0, wrapper: null };
const positioner = createGutterAutoPositioner(state);

let isDragHandleDrag = false;
let dragSourcePos = -1;
const dragImageListenerRegistered = { current: false };
let editorEventsHooked = false;

Expand Down Expand Up @@ -384,7 +433,7 @@ export function createBlockGutterDragHandle(addBlockAriaLabel: string) {
editorEventsHooked = true;
};

return DragHandle.configure({
const dragHandle = DragHandle.configure({
computePositionConfig: GUTTER_COMPUTE_POSITION_CONFIG,
onNodeChange: (raw) => {
const payload = raw as DragHandleNodeChangePayload;
Expand All @@ -402,12 +451,48 @@ export function createBlockGutterDragHandle(addBlockAriaLabel: string) {
},
onElementDragStart: () => {
isDragHandleDrag = true;
dragSourcePos = state.pos;
document.documentElement.style.setProperty('cursor', 'grabbing', 'important');

// onElementDragStart runs before TipTap's dragHandler sets view.dragging.
queueMicrotask(() => {
const editor = state.editor;
if (!editor || !isDragHandleDrag || dragSourcePos < 0) return;
patchAtomDragToNodeSelection(editor.view, dragSourcePos);
});
},
onElementDragEnd: () => {
isDragHandleDrag = false;
dragSourcePos = -1;
document.documentElement.style.removeProperty('cursor');
},
render: () => createGutterRoot(state, addBlockAriaLabel)
});

return Extension.create({
name: 'blockGutter',
addExtensions() {
return [dragHandle];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: ATOM_DRAG_MOVE_FIX_KEY,
props: {
handleDrop(view, _event, _slice, moved) {
if (!isDragHandleDrag || !moved || dragSourcePos < 0) return false;

const dragging = getViewDragging(view);
const node = view.state.doc.nodeAt(dragSourcePos);
if (node?.isAtom && !dragging?.node) {
patchAtomDragToNodeSelection(view, dragSourcePos);
}

return false;
}
}
})
];
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Injector } from '@angular/core';

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';

import { DOT_CONTENTLET_NODE_NAME, createDotContentlet } from './contentlet.extension';

function buildEditor(): Editor {
// Content stays free of `dotContent` nodes so the Angular node view never mounts —
// it needs a real Angular Injector/ApplicationRef, unavailable in a plain Jest test.
return new Editor({
extensions: [StarterKit, createDotContentlet({} as Injector)],
content: '<p></p>'
});
}

describe('Contentlet extension', () => {
let editor: Editor;

afterEach(() => {
editor?.destroy();
});

it('registers under the immutable `dotContent` node name', () => {
expect(DOT_CONTENTLET_NODE_NAME).toBe('dotContent');
editor = buildEditor();
expect(editor.schema.nodes[DOT_CONTENTLET_NODE_NAME]).toBeDefined();
});

it('is an atom node with no contentDOM', () => {
editor = buildEditor();
expect(editor.schema.nodes[DOT_CONTENTLET_NODE_NAME].isAtom).toBe(true);
});

it('does not declare itself draggable at the node-spec level (regression for #36976)', () => {
editor = buildEditor();
expect(editor.schema.nodes[DOT_CONTENTLET_NODE_NAME].spec.draggable).toBeFalsy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ export {
* Embedded dotCMS contentlet node. Canonical storage is ProseMirror JSON (`type: dotContent`,
* `attrs.data`). HTML uses {@link CONTENTLET_HTML_HOST_TAG} plus a `data` JSON attribute (skinny
* ref) for paste / export; the Angular node view is only for editing.
*
* Leave `draggable` at its default (`false`). Reordering uses the block gutter DragHandle;
* `draggable: true` on this atom raced that handle and duplicated nodes on reorder (#36976).
*/
export function createDotContentlet(injector: Injector) {
return Node.create({
name: DOT_CONTENTLET_NODE_NAME,
group: 'block',
atom: true,
draggable: true,

addAttributes() {
return {
Expand Down
Loading