Skip to content

Commit fc435e4

Browse files
dnd
1 parent fdaef55 commit fc435e4

File tree

8 files changed

+421
-23
lines changed

8 files changed

+421
-23
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import Module from '../__module';
2+
import Block from '../block';
3+
import { throttle } from '../utils';
4+
5+
enum DropPosition {
6+
Top,
7+
Bottom,
8+
}
9+
10+
/**
11+
* Module that handles drag and drop functionality for blocks.
12+
*/
13+
export default class BlockDragNDrop extends Module {
14+
public isEnabled = false;
15+
16+
private static CSS = {
17+
dragWrapper: 'ce-drag-wrapper',
18+
dragImage: 'ce-drag-image',
19+
dropHolder: 'ce-drop-holder',
20+
};
21+
22+
private dragSourceBlockId: string | null = null;
23+
24+
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
25+
private THROTTLE_SCROLL_TIMEOUT = 100;
26+
27+
private dropTargetArgs: ReturnType<typeof this.findDropTarget> | null = null;
28+
29+
private dropTargetEvents = {
30+
dragover: (event: DragEvent) => {
31+
if (event.dataTransfer) {
32+
event.dataTransfer.dropEffect = 'move';
33+
}
34+
35+
if (this.isDragOverEventSame(event)) {
36+
return;
37+
}
38+
39+
this.removeDropHolder();
40+
this.createDropHolder();
41+
},
42+
43+
dragleave: () => {
44+
this.removeDropHolder();
45+
this.dropTargetArgs = null;
46+
},
47+
48+
drop: () => {
49+
if (this.dropTargetArgs == null || this.dragSourceBlockId == null) {
50+
return;
51+
}
52+
53+
const { BlockManager } = this.Editor;
54+
const { block, position } = this.dropTargetArgs;
55+
56+
const dragSource = BlockManager.getBlockById(this.dragSourceBlockId);
57+
const dropTarget = block;
58+
59+
if (dragSource != null && dropTarget != null) {
60+
const sourceBlockIndex = BlockManager.getBlockIndex(dragSource);
61+
let targetBlockIndex = BlockManager.getBlockIndex(dropTarget);
62+
63+
if (position === DropPosition.Bottom) {
64+
targetBlockIndex++;
65+
}
66+
67+
if (sourceBlockIndex < targetBlockIndex) {
68+
targetBlockIndex--;
69+
}
70+
71+
if (targetBlockIndex !== sourceBlockIndex) {
72+
BlockManager.move(targetBlockIndex, sourceBlockIndex);
73+
}
74+
}
75+
76+
setTimeout(() => {
77+
this.removeDropHolder();
78+
79+
this.removeDragImage();
80+
this.dropTargetArgs = null;
81+
this.dragSourceBlockId = null;
82+
}, this.THROTTLE_SCROLL_TIMEOUT);
83+
},
84+
};
85+
86+
private dragHandleEvents = {
87+
dragstart: (event: Event, getDragSourceBlock: () => Block | null) => {
88+
const dragSourceBlock = getDragSourceBlock();
89+
90+
if (
91+
dragSourceBlock != null &&
92+
event instanceof DragEvent &&
93+
event.dataTransfer
94+
) {
95+
this.registerBlockEvents();
96+
97+
this.dragSourceBlockId = dragSourceBlock.id;
98+
const dragImage = this.createDragImage(dragSourceBlock);
99+
100+
event.dataTransfer.setDragImage(dragImage, 0, 0);
101+
event.dataTransfer.effectAllowed = 'move';
102+
}
103+
},
104+
105+
dragend: () => this.destroy(),
106+
} as const;
107+
108+
private bringElementIntoViewThrottled = throttle(
109+
(element: HTMLElement) =>
110+
element.scrollIntoView({
111+
behavior: 'smooth',
112+
block: 'center',
113+
}),
114+
this.THROTTLE_SCROLL_TIMEOUT
115+
);
116+
117+
/**
118+
* Enables the drag handle by setting its draggable attribute and returns the associated event bindings.
119+
*
120+
* @param dragHandle - The HTML element that serves as the drag handle.
121+
* @returns The drag handle event bindings.
122+
*/
123+
public getDragHandleModuleBindings(
124+
dragHandle: HTMLElement
125+
): typeof this.dragHandleEvents {
126+
dragHandle.setAttribute('draggable', 'true');
127+
this.isEnabled = true;
128+
129+
return this.dragHandleEvents;
130+
}
131+
132+
/**
133+
* Disables the drag handle by removing its draggable attribute and cleans up event listeners.
134+
*
135+
* @param dragHandle - The HTML element acting as the drag handle.
136+
*/
137+
public disableDragHandleModuleBindings(dragHandle: HTMLElement): void {
138+
dragHandle.removeAttribute('draggable');
139+
this.isEnabled = false;
140+
141+
this.destroy();
142+
}
143+
144+
/**
145+
* Registers drag event listeners for each block in the BlockManager.
146+
* Iterates over all blocks and attaches listeners for dragover, dragleave, and drop events.
147+
*
148+
* @private
149+
*/
150+
private registerBlockEvents(): void {
151+
const { UI } = this.Editor;
152+
153+
const dropTarget = UI.nodes.redactor;
154+
155+
for (const [eventName, eventHandler] of Object.entries(
156+
this.dropTargetEvents
157+
)) {
158+
this.readOnlyMutableListeners.on(
159+
dropTarget,
160+
eventName,
161+
(event: Event) => {
162+
if (event instanceof DragEvent) {
163+
eventHandler(event);
164+
}
165+
}
166+
);
167+
}
168+
}
169+
170+
private createDropHolder = (): HTMLElement | null => {
171+
if (!this.dropTargetArgs) {
172+
return null;
173+
}
174+
175+
const { block, position } = this.dropTargetArgs;
176+
177+
const dropHolder = document.createElement('div');
178+
179+
dropHolder.classList.add(
180+
BlockDragNDrop.CSS.dragWrapper,
181+
BlockDragNDrop.CSS.dropHolder
182+
);
183+
184+
block.holder.insertAdjacentElement(
185+
position === DropPosition.Top ? 'beforebegin' : 'afterend',
186+
dropHolder
187+
);
188+
189+
this.bringElementIntoViewThrottled(dropHolder);
190+
191+
return dropHolder;
192+
};
193+
194+
private removeDropHolder = (): void => {
195+
this.Editor.UI.nodes.redactor
196+
.querySelectorAll(
197+
`.${BlockDragNDrop.CSS.dragWrapper}.${BlockDragNDrop.CSS.dropHolder}`
198+
)
199+
.forEach((dropHolder) => {
200+
dropHolder.remove();
201+
});
202+
};
203+
204+
/**
205+
* Cleans up the drag and drop state by removing drag images, resetting internal state,
206+
* and clearing all event listeners attached to block holders.
207+
*
208+
* @returns A promise that resolves once the cleanup is complete.
209+
* @private
210+
*/
211+
private async destroy(): Promise<void> {
212+
this.removeDragImage();
213+
this.removeDropHolder();
214+
215+
this.dragSourceBlockId = null;
216+
this.dropTargetArgs = null;
217+
218+
this.readOnlyMutableListeners.clearAll();
219+
}
220+
221+
private createDragImage = (block: Block): HTMLElement => {
222+
const dragImage = block.holder.cloneNode(true) as HTMLElement;
223+
224+
dragImage.classList.add(
225+
BlockDragNDrop.CSS.dragWrapper,
226+
BlockDragNDrop.CSS.dragImage
227+
);
228+
this.Editor.UI.nodes.redactor.appendChild(dragImage);
229+
230+
return dragImage;
231+
};
232+
233+
/**
234+
* Removes any existing drag image elements from the editor's redactor.
235+
*
236+
* @private
237+
*/
238+
private removeDragImage(): void {
239+
this.Editor.UI.nodes.redactor
240+
.querySelectorAll(
241+
`.${BlockDragNDrop.CSS.dragWrapper}.${BlockDragNDrop.CSS.dragImage}`
242+
)
243+
.forEach((dragImage) => {
244+
dragImage.remove();
245+
});
246+
}
247+
248+
/**
249+
* optimizes dragover event by checking if the closest block and drop position are the same
250+
* as the last dragover event.
251+
* If they are, it returns true to avoid unnecessary processing.
252+
*
253+
* @param event - The drag event
254+
*/
255+
private isDragOverEventSame(event: DragEvent): boolean {
256+
const dropTargetArgs = this.findDropTarget(event);
257+
258+
if (dropTargetArgs == null) {
259+
return false;
260+
}
261+
262+
if (
263+
dropTargetArgs.block === this.dropTargetArgs?.block &&
264+
dropTargetArgs.position === this.dropTargetArgs?.position
265+
) {
266+
// if the closest blocks are the same, return true
267+
return true;
268+
}
269+
270+
this.dropTargetArgs = dropTargetArgs;
271+
272+
return false;
273+
}
274+
275+
/**
276+
* Finds the closest blocks above and below the mouse event.
277+
*
278+
* @param mouseEvent - The mouse event.
279+
* @returns An object with the closest top and bottom blocks.
280+
*/
281+
private findDropTarget = (
282+
mouseEvent: MouseEvent
283+
): { block: Block; position: DropPosition } | null => {
284+
const { BlockManager } = this.Editor;
285+
286+
const { clientX, clientY } = mouseEvent;
287+
const elementAtMouseEvent = document.elementFromPoint(clientX, clientY);
288+
289+
if (elementAtMouseEvent != null) {
290+
const closestElement = elementAtMouseEvent.closest(
291+
`.${Block.CSS.wrapper}`
292+
);
293+
const block = BlockManager.blocks.find((_block) => {
294+
return _block.holder === closestElement;
295+
});
296+
297+
if (block) {
298+
const { top, bottom } = block.holder.getBoundingClientRect();
299+
300+
return {
301+
block,
302+
position:
303+
clientY < (top + bottom) / 2
304+
? DropPosition.Top
305+
: DropPosition.Bottom,
306+
};
307+
}
308+
}
309+
310+
return null;
311+
};
312+
}
313+
314+
// TODO
315+
// 1 - fix scroll - use rectangle selection logic

src/components/modules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import Renderer from './renderer';
3737
import Saver from './saver';
3838
import Tools from './tools';
3939
import UI from './ui';
40+
import BlockDragNDrop from './blockDragNDrop';
4041

4142
export default {
4243
// API Modules
@@ -64,6 +65,7 @@ export default {
6465
InlineToolbar,
6566

6667
// Modules
68+
BlockDragNDrop,
6769
BlockEvents,
6870
BlockManager,
6971
BlockSelection,

0 commit comments

Comments
 (0)