diff --git a/src/components/modules/blockDragNDrop.ts b/src/components/modules/blockDragNDrop.ts
new file mode 100644
index 000000000..8afe548f1
--- /dev/null
+++ b/src/components/modules/blockDragNDrop.ts
@@ -0,0 +1,588 @@
+import Module from '../__module';
+import Block from '../block';
+import { BlockHovered } from '../events/BlockHovered';
+import SelectionUtils from '../selection';
+
+enum DropPosition {
+ Top,
+ Bottom,
+}
+
+/**
+ * Module that handles drag and drop functionality for blocks.
+ */
+export default class BlockDragNDrop extends Module {
+ private static CSS = {
+ dragWrapper: 'ce-drag-wrapper',
+ dragImage: 'ce-drag-image',
+ dropHolder: 'ce-drop-holder',
+ };
+
+ /**
+ * Custom data attributes that allow consumers to customize drag-and-drop scrolling behavior.
+ *
+ * These attributes can be added to the editor holder element to control how auto-scrolling works when
+ * dragging blocks near the edges of the viewport.
+ *
+ * @property {string} topScrollThreshold - Attribute to customize the distance (in pixels) from the top edge
+ * that triggers scrolling. Must be a positive number.
+ * @property {string} bottomScrollThreshold - Attribute to customize the distance (in pixels) from the bottom edge
+ * that triggers scrolling. Must be a positive number.
+ * @example
+ * HTML example of customization on the editor holder element:
+ *
+ *
+ *
+ */
+ private customDataAttributes = {
+ topScrollThreshold: 'data-ce-top-scroll-threshold',
+ bottomScrollThreshold: 'data-ce-bottom-scroll-threshold',
+ };
+
+ private dragSourceBlockId: string | null = null;
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ private SCROLL_SPEED = 25; // Base scroll speed in pixels
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ private DRAG_SCROLL_THRESHOLD = 100; // Distance from edge to trigger scroll
+
+ private scrollAnimationId: number | null = null;
+ private scrollContainerCache: HTMLElement | null = null;
+ private dropTargetArgs: ReturnType | null = null;
+ private hoveredBlock: Block | null = null;
+
+ private dropTargetEvents = {
+ dragover: (event: DragEvent) => {
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = 'move';
+ }
+
+ if (this.isDragOverEventSame(event)) {
+ return;
+ }
+
+ this.handleAutoScroll(event.clientY);
+
+ this.removeDropHolder();
+ this.createDropHolder();
+ },
+
+ dragleave: () => {
+ this.stopAutoScroll();
+ this.removeDropHolder();
+ this.dropTargetArgs = null;
+ },
+
+ drop: () => {
+ this.stopAutoScroll();
+
+ if (this.dropTargetArgs == null || this.dragSourceBlockId == null) {
+ return;
+ }
+
+ const { BlockManager, BlockSelection } = this.Editor;
+ const { block, position } = this.dropTargetArgs;
+
+ const dragSource = BlockManager.getBlockById(this.dragSourceBlockId);
+ const dropTarget = block;
+
+ if (dragSource != null && dropTarget != null) {
+ const sourceBlockIndex = BlockManager.getBlockIndex(dragSource);
+ let targetBlockIndex = BlockManager.getBlockIndex(dropTarget);
+
+ if (position === DropPosition.Bottom) {
+ targetBlockIndex++;
+ }
+
+ if (sourceBlockIndex < targetBlockIndex) {
+ targetBlockIndex--;
+ }
+
+ if (targetBlockIndex !== sourceBlockIndex) {
+ BlockManager.move(targetBlockIndex, sourceBlockIndex);
+ BlockSelection.selectBlockByIndex(targetBlockIndex);
+ }
+ }
+
+ this.removeDropHolder();
+
+ this.removeDragImage();
+ this.dropTargetArgs = null;
+ this.dragSourceBlockId = null;
+ },
+ };
+
+ /**
+ * Toggles read-only mode
+ *
+ * @param {boolean} readOnlyEnabled - read-only mode
+ */
+ public toggleReadOnly(readOnlyEnabled: boolean): void {
+ if (!readOnlyEnabled) {
+ window.requestIdleCallback(
+ () => {
+ this.enableModuleBindings();
+ this.initHoveredBlockListener();
+ },
+ { timeout: 2000 }
+ );
+ } else {
+ this.disableModuleBindings();
+ this.destroy();
+ }
+ }
+
+ /**
+ * listens to the block hovered event and sets the hoveredBlock property
+ */
+ private initHoveredBlockListener(): void {
+ this.eventsDispatcher.on(BlockHovered, (data) => {
+ this.hoveredBlock = data.block;
+ });
+ }
+
+ /**
+ * Enables the drag handle by setting its draggable attribute and adds the associated event bindings.
+ */
+ private enableModuleBindings(): void {
+ const dragHandle = this.Editor.Toolbar.nodes.settingsToggler;
+
+ if (dragHandle) {
+ dragHandle.setAttribute('draggable', 'true');
+
+ this.readOnlyMutableListeners.on(
+ dragHandle,
+ 'dragstart',
+ (event: Event) => {
+ const { BlockSettings } = this.Editor;
+
+ // close the tool settings popover
+ BlockSettings.close();
+ // clear any selection to prevent interference with drag and drop
+ SelectionUtils.get()?.removeAllRanges();
+
+ const dragSourceBlock = this.hoveredBlock;
+
+ if (
+ dragSourceBlock != null &&
+ event instanceof DragEvent &&
+ event.dataTransfer
+ ) {
+ this.registerBlockEvents();
+
+ this.dragSourceBlockId = dragSourceBlock.id;
+ const dragImage = this.createDragImage(dragSourceBlock);
+
+ event.dataTransfer.setDragImage(dragImage, 0, 0);
+ event.dataTransfer.effectAllowed = 'move';
+ }
+ },
+ { passive: true,
+ capture: true }
+ );
+
+ this.readOnlyMutableListeners.on(
+ dragHandle,
+ 'dragend',
+ () => {
+ this.clearResources();
+ },
+ { passive: true,
+ capture: true }
+ );
+ }
+ }
+
+ /**
+ * Disables the drag handle by removing its draggable attribute and cleans up event listeners.
+ */
+ private disableModuleBindings(): void {
+ const dragHandle = this.Editor.Toolbar.nodes.settingsToggler;
+
+ if (dragHandle) {
+ dragHandle.removeAttribute('draggable');
+ }
+
+ this.clearResources();
+ }
+
+ /**
+ * Registers drag event listeners for each block in the BlockManager.
+ * Iterates over all blocks and attaches listeners for dragover, dragleave, and drop events.
+ *
+ * @private
+ */
+ private registerBlockEvents(): void {
+ const { UI } = this.Editor;
+
+ const dropTarget = UI.nodes.redactor;
+
+ for (const [eventName, eventHandler] of Object.entries(
+ this.dropTargetEvents
+ )) {
+ this.readOnlyMutableListeners.on(
+ dropTarget,
+ eventName,
+ (event: Event) => {
+ if (event instanceof DragEvent) {
+ eventHandler(event);
+ }
+ },
+ { passive: true,
+ capture: true }
+ );
+ }
+ }
+
+ /**
+ * Creates and inserts a drop holder element at the target position.
+ * This visual element indicates where the dragged block will be placed.
+ *
+ * @returns The created drop holder element or null if no drop target is available
+ * @private
+ */
+ private createDropHolder = (): HTMLElement | null => {
+ if (!this.dropTargetArgs) {
+ return null;
+ }
+
+ const { block, position } = this.dropTargetArgs;
+
+ const dropHolder = document.createElement('div');
+
+ dropHolder.classList.add(
+ BlockDragNDrop.CSS.dragWrapper,
+ BlockDragNDrop.CSS.dropHolder
+ );
+
+ block.holder.insertAdjacentElement(
+ position === DropPosition.Top ? 'beforebegin' : 'afterend',
+ dropHolder
+ );
+
+ return dropHolder;
+ };
+
+ /**
+ * Removes all drop holder elements from the editor.
+ * This cleans up visual indicators after a drag operation completes.
+ */
+ private removeDropHolder(): void {
+ this.Editor.UI.nodes.redactor
+ .querySelectorAll(
+ `.${BlockDragNDrop.CSS.dragWrapper}.${BlockDragNDrop.CSS.dropHolder}`
+ )
+ .forEach((dropHolder) => {
+ dropHolder.remove();
+ });
+ }
+
+ /**
+ * Cleans up the drag and drop state by removing drag images, resetting internal state
+ */
+ private clearResources(): void {
+ this.stopAutoScroll();
+ this.removeDragImage();
+ this.removeDropHolder();
+
+ this.dragSourceBlockId = null;
+ this.dropTargetArgs = null;
+ this.scrollContainerCache = null;
+ }
+
+ /**
+ * clears all event listeners attached to block holders
+ */
+ private destroy(): void {
+ this.hoveredBlock = null;
+ this.readOnlyMutableListeners.clearAll();
+ }
+
+ /**
+ * Creates a visual representation of the block being dragged.
+ *
+ * @param block - The block being dragged
+ * @returns The element representing the dragged block
+ * @private
+ */
+ private createDragImage = (block: Block): HTMLElement => {
+ const dragImage = block.holder.cloneNode(true) as HTMLElement;
+
+ dragImage.classList.add(
+ BlockDragNDrop.CSS.dragWrapper,
+ BlockDragNDrop.CSS.dragImage
+ );
+ this.Editor.UI.nodes.redactor.appendChild(dragImage);
+
+ return dragImage;
+ };
+
+ /**
+ * Removes any existing drag image elements from the editor's redactor.
+ *
+ * @private
+ */
+ private removeDragImage(): void {
+ this.Editor.UI.nodes.redactor
+ .querySelectorAll(
+ `.${BlockDragNDrop.CSS.dragWrapper}.${BlockDragNDrop.CSS.dragImage}`
+ )
+ .forEach((dragImage) => {
+ dragImage.remove();
+ });
+ }
+
+ /**
+ * optimizes dragover event by checking if the closest block and drop position are the same
+ * as the last dragover event.
+ * If they are, it returns true to avoid unnecessary processing.
+ *
+ * @param event - The drag event
+ */
+ private isDragOverEventSame(event: DragEvent): boolean {
+ const dropTargetArgs = this.findDropTarget(event);
+
+ if (dropTargetArgs == null) {
+ return false;
+ }
+
+ if (
+ dropTargetArgs.block === this.dropTargetArgs?.block &&
+ dropTargetArgs.position === this.dropTargetArgs?.position
+ ) {
+ // if the closest blocks are the same, return true
+ return true;
+ }
+
+ this.dropTargetArgs = dropTargetArgs;
+
+ return false;
+ }
+
+ /**
+ * Finds the closest blocks above and below the mouse event.
+ *
+ * @param mouseEvent - The mouse event.
+ * @returns An object with the closest top and bottom blocks.
+ */
+ private findDropTarget = (
+ mouseEvent: MouseEvent
+ ): { block: Block; position: DropPosition } | null => {
+ const { BlockManager } = this.Editor;
+
+ const { clientX, clientY } = mouseEvent;
+ const elementAtMouseEvent = document.elementFromPoint(clientX, clientY);
+
+ if (elementAtMouseEvent != null) {
+ const closestElement = elementAtMouseEvent.closest(
+ `.${Block.CSS.wrapper}`
+ );
+ const block = BlockManager.blocks.find((_block) => {
+ return _block.holder === closestElement;
+ });
+
+ if (block) {
+ const { top, bottom } = block.holder.getBoundingClientRect();
+
+ return {
+ block,
+ position:
+ clientY < (top + bottom) / 2
+ ? DropPosition.Top
+ : DropPosition.Bottom,
+ };
+ }
+ }
+
+ return null;
+ };
+
+ /**
+ * Finds the scrollable parent of an element
+ *
+ * @param element - The element to find the scrollable parent for
+ * @returns The scrollable parent element
+ * @private
+ */
+ private findScrollContainer(element: HTMLElement): HTMLElement {
+ if (this.scrollContainerCache) {
+ return this.scrollContainerCache;
+ }
+
+ // Otherwise find the scrollable parent automatically
+ let parent = element.parentElement;
+
+ while (parent) {
+ const { overflowY } = window.getComputedStyle(parent);
+
+ if (overflowY === 'auto' || overflowY === 'scroll') {
+ this.scrollContainerCache = parent;
+
+ return parent;
+ }
+ parent = parent.parentElement;
+ }
+
+ // If no scrollable parent is found, use document.scrollingElement or body as fallback
+ const _scrollContainer = (document.scrollingElement ||
+ document.body) as HTMLElement;
+
+ this.scrollContainerCache = _scrollContainer;
+
+ return _scrollContainer;
+ }
+
+ /**
+ * Gets custom threshold values from the editor holder if specified
+ *
+ * @returns Object with top and bottom threshold values in pixels
+ * @private
+ */
+ private getScrollThresholds(): {
+ top: number;
+ bottom: number;
+ } {
+ // Default thresholds
+ const defaults = {
+ top: this.DRAG_SCROLL_THRESHOLD,
+ bottom: this.DRAG_SCROLL_THRESHOLD,
+ };
+
+ const { holder } = this.Editor.UI.nodes;
+
+ // Check for custom thresholds on the editor holder
+ const topThresholdAttr = holder.getAttribute(
+ this.customDataAttributes.topScrollThreshold
+ );
+ const bottomThresholdAttr = holder.getAttribute(
+ this.customDataAttributes.bottomScrollThreshold
+ );
+
+ // Parse with validation
+ let top = defaults.top;
+ let bottom = defaults.bottom;
+
+ if (topThresholdAttr !== null) {
+ const parsedTop = parseInt(topThresholdAttr, 10);
+
+ // Check if the parsed value is a valid number and is positive
+ if (!isNaN(parsedTop) && parsedTop >= 0) {
+ top = parsedTop;
+ }
+ }
+
+ if (bottomThresholdAttr !== null) {
+ const parsedBottom = parseInt(bottomThresholdAttr, 10);
+
+ // Check if the parsed value is a valid number and is positive
+ if (!isNaN(parsedBottom) && parsedBottom >= 0) {
+ bottom = parsedBottom;
+ }
+ }
+
+ return { top,
+ bottom };
+ }
+
+ /**
+ * Handles auto-scrolling when dragging near viewport edges
+ *
+ * @param clientY - Y coordinate of the drag event
+ * @private
+ */
+ private handleAutoScroll(clientY: number): void {
+ // Cancel any existing scroll animation
+ if (this.scrollAnimationId !== null) {
+ window.cancelAnimationFrame(this.scrollAnimationId);
+ this.scrollAnimationId = null;
+ }
+
+ const { UI } = this.Editor;
+ const holder = UI.nodes.holder;
+
+ // Find the actual scroll container
+ const scrollContainer = this.findScrollContainer(holder);
+
+ // Get custom thresholds if specified
+ const thresholds = this.getScrollThresholds();
+
+ // Calculate distance from viewport edges
+ const distanceFromTop = clientY; // From viewport top
+ const distanceFromBottom = window.innerHeight - clientY; // From viewport bottom
+
+ const isNearTopEdge = distanceFromTop < thresholds.top;
+ const isNearBottomEdge = distanceFromBottom < thresholds.bottom;
+ const isNearEdge = isNearTopEdge || isNearBottomEdge;
+
+ // Determine if we need to scroll
+ if (isNearEdge) {
+ this.startAutoScroll(
+ distanceFromTop,
+ distanceFromBottom,
+ scrollContainer,
+ thresholds
+ );
+ }
+ }
+
+ /**
+ * Starts the auto-scroll animation
+ *
+ * @param distanceFromTop - Distance from viewport top edge
+ * @param distanceFromBottom - Distance from viewport bottom edge
+ * @param scrollContainer - The container element that should be scrolled
+ * @param thresholds - Custom threshold values
+ * @private
+ */
+ private startAutoScroll(
+ distanceFromTop: number,
+ distanceFromBottom: number,
+ scrollContainer: HTMLElement,
+ thresholds: { top: number; bottom: number } = {
+ top: this.DRAG_SCROLL_THRESHOLD,
+ bottom: this.DRAG_SCROLL_THRESHOLD,
+ }
+ ): void {
+ // Animation function for smooth scrolling
+ const scroll = (): void => {
+ let scrollAmount = 0;
+
+ // Calculate scroll amount and direction based on proximity to viewport edges
+ if (distanceFromTop < thresholds.top) {
+ // Scrolling up - faster as you get closer to the edge
+ const intensity = 1 - distanceFromTop / thresholds.top;
+
+ scrollAmount = -Math.ceil(this.SCROLL_SPEED * intensity);
+ } else if (distanceFromBottom < thresholds.bottom) {
+ // Scrolling down - faster as you get closer to the edge
+ const intensity = 1 - distanceFromBottom / thresholds.bottom;
+
+ scrollAmount = Math.ceil(this.SCROLL_SPEED * intensity);
+ }
+
+ if (scrollAmount !== 0) {
+ // Apply scrolling to the correct container
+ scrollContainer.scrollBy(0, scrollAmount);
+ // Continue animation
+ this.scrollAnimationId = window.requestAnimationFrame(scroll);
+ } else {
+ this.scrollAnimationId = null;
+ }
+ };
+
+ // Start the animation
+ this.scrollAnimationId = window.requestAnimationFrame(scroll);
+ }
+
+ /**
+ * Stops any ongoing auto-scroll animation
+ *
+ * @private
+ */
+ private stopAutoScroll(): void {
+ if (this.scrollAnimationId !== null) {
+ window.cancelAnimationFrame(this.scrollAnimationId);
+ this.scrollAnimationId = null;
+ }
+ }
+}
diff --git a/src/components/modules/index.ts b/src/components/modules/index.ts
index 2c945f43d..e1ecb1999 100644
--- a/src/components/modules/index.ts
+++ b/src/components/modules/index.ts
@@ -37,6 +37,7 @@ import Renderer from './renderer';
import Saver from './saver';
import Tools from './tools';
import UI from './ui';
+import BlockDragNDrop from './blockDragNDrop';
export default {
// API Modules
@@ -64,6 +65,7 @@ export default {
InlineToolbar,
// Modules
+ BlockDragNDrop,
BlockEvents,
BlockManager,
BlockSelection,
diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts
index 3098c6aba..fd56b9bc7 100644
--- a/src/components/modules/toolbar/index.ts
+++ b/src/components/modules/toolbar/index.ts
@@ -469,18 +469,7 @@ export default class Toolbar extends Module {
$.append(this.nodes.actions, this.nodes.settingsToggler);
- const blockTunesTooltip = $.make('div');
- const blockTunesTooltipEl = $.text(I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'));
- const slashRealKey = await getKeyboardKeyForCode('Slash', '/');
-
- blockTunesTooltip.appendChild(blockTunesTooltipEl);
- blockTunesTooltip.appendChild($.make('div', this.CSS.plusButtonShortcut, {
- textContent: beautifyShortcut(`CMD + ${slashRealKey}`),
- }));
-
- tooltip.onHover(this.nodes.settingsToggler, blockTunesTooltip, {
- hidingDelay: 400,
- });
+ await this.makeSettingsTogglerTooltip();
/**
* Appending Toolbar components to itself
@@ -494,6 +483,36 @@ export default class Toolbar extends Module {
$.append(this.Editor.UI.nodes.wrapper, this.nodes.wrapper);
}
+ /**
+ * Creates and configures a tooltip for the settings toggler button in the toolbar.
+ *
+ * @returns {Promise} A promise that resolves when the tooltip is successfully created.
+ */
+ private async makeSettingsTogglerTooltip(): Promise {
+ const tooltipTextParts: string[] = [
+ I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'),
+ ];
+
+ tooltipTextParts.push(
+ I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'or drag to move')
+ );
+
+ const blockTunesTooltip = $.make('div');
+ const blockTunesTooltipEl = $.text(tooltipTextParts.join(' '));
+ const slashRealKey = await getKeyboardKeyForCode('Slash', '/');
+
+ blockTunesTooltip.appendChild(blockTunesTooltipEl);
+ blockTunesTooltip.appendChild(
+ $.make('div', this.CSS.plusButtonShortcut, {
+ textContent: beautifyShortcut(`CMD + ${slashRealKey}`),
+ })
+ );
+
+ tooltip.onHover(this.nodes.settingsToggler, blockTunesTooltip, {
+ hidingDelay: 400,
+ });
+ }
+
/**
* Creates the Toolbox instance and return it's rendered element
*/
diff --git a/src/styles/blockDragNDrop.css b/src/styles/blockDragNDrop.css
new file mode 100644
index 000000000..61164811a
--- /dev/null
+++ b/src/styles/blockDragNDrop.css
@@ -0,0 +1,30 @@
+.ce-drag-wrapper {
+ &.ce-drag-image {
+ position: absolute;
+ pointer-events: none;
+ z-index: 9999;
+ opacity: 0.5;
+ transition: none;
+ user-select: none;
+ width: fit-content;
+ top: -10000px;
+ left: -10000px;
+ background: var(--selectionColor);
+ }
+
+ &.ce-drop-holder {
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ height: 5px;
+ background-color: #388ae5;
+ opacity: 0.5;
+ z-index: 9999;
+ }
+ }
+}
diff --git a/src/styles/main.css b/src/styles/main.css
index d0005e75c..902dc2fb4 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -12,4 +12,4 @@
@import './popover.css';
@import './popover-inline.css';
@import './placeholders.css';
-
+@import './blockDragNDrop.css';
diff --git a/src/types-internal/editor-modules.d.ts b/src/types-internal/editor-modules.d.ts
index 3e455c349..17ea311b3 100644
--- a/src/types-internal/editor-modules.d.ts
+++ b/src/types-internal/editor-modules.d.ts
@@ -37,6 +37,7 @@ import Saver from '../components/modules/saver';
import Tools from '../components/modules/tools';
import UI from '../components/modules/ui';
import ToolsAPI from '../components/modules/api/tools';
+import BlockDragNDrop from '../components/modules/blockDragNDrop';
export interface EditorModules {
// API Modules
@@ -64,6 +65,7 @@ export interface EditorModules {
InlineToolbar: InlineToolbar,
// Modules
+ BlockDragNDrop: BlockDragNDrop,
BlockEvents: BlockEvents,
BlockManager: BlockManager,
BlockSelection: BlockSelection,