diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts index 2869af7b1cb9..d424cba57233 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts @@ -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( @@ -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( diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.spec.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.spec.ts new file mode 100644 index 000000000000..8ed0c873eb37 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.spec.ts @@ -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: '

Hello

World

' + }); + 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(); + }); +}); diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts index 6eb1f20e1cfb..e7ba9d12499c 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts @@ -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. @@ -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. * @@ -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; @@ -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; @@ -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; + } + } + }) + ]; + } + }); } diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet/contentlet.extension.spec.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet/contentlet.extension.spec.ts new file mode 100644 index 000000000000..5eeec3626612 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet/contentlet.extension.spec.ts @@ -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: '

' + }); +} + +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(); + }); +}); diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet/contentlet.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet/contentlet.extension.ts index 17e0dccdb02d..f032b17453fb 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet/contentlet.extension.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet/contentlet.extension.ts @@ -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 {