Skip to content

Commit 521a689

Browse files
fix(editor): show not-allowed cursor during cross-editor drag (#4546)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]> Co-authored-by: Stephen Chen <[email protected]>
1 parent 5960612 commit 521a689

File tree

3 files changed

+54
-4
lines changed

3 files changed

+54
-4
lines changed

packages/fern-dashboard/src/components/editor/NodeHoverHandle.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,19 @@ export default function NodeHoverHandle() {
5454
draggable
5555
onDragStart={(event) => {
5656
try {
57+
// Persist the origin editor id for dragover (dataTransfer.getData isn't readable during dragover in many browsers).
58+
// Also tag the drag with a custom MIME type so dragover can scope behavior to handle-initiated drags.
59+
(window as any).__fernDraggingEditorId = editorId;
5760
event.dataTransfer?.setData("editor-id", editorId);
5861
event.dataTransfer?.setData("application/x-tiptap-dnd", "1");
62+
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
5963
} catch {}
6064
}}
65+
onDragEnd={() => {
66+
// Always clear the global state and body-level cursor when the drag ends (covers drops outside the editor too).
67+
(window as any).__fernDraggingEditorId = undefined;
68+
document.body.classList.remove("fern-dragging-blocked");
69+
}}
6170
className="fern-hover-handle flex items-center justify-center rounded-md py-1 px-0.5 hover:bg-gray-500/40 cursor-grab leading-none"
6271
>
6372
<GripVertical className="text-muted-foreground" size={16} />

packages/fern-dashboard/src/components/editor/TiptapEditor.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import StarterKit from "@tiptap/starter-kit";
1515
import { useEffect, useMemo, useRef } from "react";
1616

1717
import "@/components/editor/tiptap-node/node-focus/node-focus.scss";
18+
import "./drag-cursor.css";
1819
import { useEditingDisabled } from "@/hooks/useEditingDisabled";
1920
import { useEditor } from "@/providers/EditorContext";
2021
import { cn } from "@/utils/utils";
@@ -205,28 +206,59 @@ export default function TiptapEditor({
205206
// Block drops from other editors (e.g., outer → nested or vice versa) by comparing editor IDs
206207
drop: (view, event) => {
207208
const e = event as unknown as DragEvent;
209+
const isOurDnD = e.dataTransfer?.types?.includes("application/x-tiptap-dnd");
210+
document.body.classList.remove("fern-dragging-blocked");
211+
if (!isOurDnD) {
212+
return false; // Not our drag, don't process
213+
}
208214
const fromId = e.dataTransfer?.getData("editor-id") || "";
209215
const toId = (view.dom as HTMLElement)?.getAttribute("data-editor-id") || "";
210216
if (fromId && toId && fromId !== toId) {
211217
e.preventDefault();
212218
e.stopPropagation();
219+
// Clear global state on drop
220+
(window as any).__fernDraggingEditorId = undefined;
213221
return true;
214222
}
223+
// Clear global state on successful drop
224+
(window as any).__fernDraggingEditorId = undefined;
215225
return false;
216226
},
217-
// Show "not-allowed" cursor when dragging between different editors
227+
// When dragging across different editors, block the drop and show a global "not-allowed" cursor.
228+
// We set a class on document.body (not the editor element) so the cursor wins over any child-level
229+
// cursor styles (e.g. .ProseMirror { cursor: text } or cursor: pointer on nodes).
230+
// We keep the class until drop/dragend to avoid flicker while moving between DOM children.
218231
dragover: (view, event) => {
219232
const e = event as unknown as DragEvent;
220-
const fromId = e.dataTransfer?.getData("editor-id") || "";
233+
// Read from global store since dataTransfer.getData doesn't work in dragover
234+
const fromId = (window as any).__fernDraggingEditorId || "";
221235
const toId = (view.dom as HTMLElement)?.getAttribute("data-editor-id") || "";
222-
if (fromId && toId && fromId !== toId) {
236+
// Only apply this logic to handle-initiated drags. This avoids changing the cursor for unrelated drags (e.g., text or external files).
237+
const isOurDnD = e.dataTransfer?.types?.includes("application/x-tiptap-dnd");
238+
if (isOurDnD && fromId && toId && fromId !== toId) {
239+
// Call preventDefault so browser respects dropEffect
240+
e.preventDefault();
223241
try {
224242
if (e.dataTransfer) e.dataTransfer.dropEffect = "none";
225243
} catch {}
226-
e.preventDefault();
227244
e.stopPropagation();
245+
// Add CSS class to body for cursor display
246+
document.body.classList.add("fern-dragging-blocked");
228247
return true;
229248
}
249+
// If this drag isn't blocked, ensure the global cursor class is removed.
250+
document.body.classList.remove("fern-dragging-blocked");
251+
return false;
252+
},
253+
dragleave: (view, event) => {
254+
// Do not remove the global cursor class on dragleave. Moving between children fires dragleave
255+
// continuously and would cause cursor flicker. Cleanup happens on dragover (when unblocked), drop, or dragend.
256+
return false;
257+
},
258+
dragend: (view, event) => {
259+
// Clean up cursor class and global state when drag ends
260+
document.body.classList.remove("fern-dragging-blocked");
261+
(window as any).__fernDraggingEditorId = undefined;
230262
return false;
231263
}
232264
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*
2+
Apply the blocked-drag cursor at the body level so it appears anywhere the pointer can move during a blocked drag.
3+
The descendant selector ensures we override element-level cursor styles (cursor is not inherited).
4+
!important is required to beat component-level rules like .ProseMirror { cursor: text }.
5+
*/
6+
body.fern-dragging-blocked,
7+
body.fern-dragging-blocked * {
8+
cursor: not-allowed !important;
9+
}

0 commit comments

Comments
 (0)