Skip to content

Commit 2a8d8e4

Browse files
authored
feat: add drag-and-drop support for displaying file contents in text editor (#5)
* draft: useDragAndDrop hook * fix: resolve drag state race condition by updating state in dragOver * feat: enhance drag and drop with type safety
1 parent 5c67299 commit 2a8d8e4

2 files changed

Lines changed: 239 additions & 21 deletions

File tree

src/components/diff/TextDiff.tsx

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,57 @@
11
import { useState, useRef } from 'react';
22
import { DiffEditor, type MonacoDiffEditor } from '@monaco-editor/react';
33
import { useTheme } from '../../hooks/useTheme';
4+
import { useDragAndDrop } from '../../hooks/useDragAndDrop';
45

56
interface TextDiffProps {
67
className?: string;
78
}
89

10+
type DropZone = 'original' | 'modified';
11+
12+
const DROP_ZONE = {
13+
ORIGINAL: 'original' as const,
14+
MODIFIED: 'modified' as const,
15+
} satisfies Record<string, DropZone>;
16+
917
export const TextDiff = ({ className = '' }: TextDiffProps) => {
1018
const [originalText, setOriginalText] = useState('function hello() {\n console.log("Hello World");\n}');
1119
const [modifiedText, setModifiedText] = useState('function hello() {\n console.log("Hello, World!");\n return "Hello";\n}');
1220
const editorRef = useRef<MonacoDiffEditor | null>(null);
21+
const originalDropZoneRef = useRef<HTMLElement | null>(null);
22+
const modifiedDropZoneRef = useRef<HTMLElement | null>(null);
1323
const { theme } = useTheme();
1424

25+
const { isDragging, activeDropZone, registerDropZone } = useDragAndDrop<DropZone>({
26+
onFilesDrop: (files, dropZone) => {
27+
const file = files[0];
28+
readFile(file, dropZone);
29+
},
30+
});
31+
1532
const handleEditorDidMount = (editor: MonacoDiffEditor) => {
1633
editorRef.current = editor;
34+
originalDropZoneRef.current = editor.getOriginalEditor().getDomNode();
35+
modifiedDropZoneRef.current = editor.getModifiedEditor().getDomNode();
36+
if (originalDropZoneRef.current) {
37+
registerDropZone(originalDropZoneRef.current, DROP_ZONE.ORIGINAL);
38+
}
39+
if (modifiedDropZoneRef.current) {
40+
registerDropZone(modifiedDropZoneRef.current, DROP_ZONE.MODIFIED);
41+
}
42+
};
43+
44+
const readFile = (file: File, side: DropZone) => {
45+
const reader = new FileReader();
46+
reader.onload = (e) => {
47+
const content = e.target?.result as string;
48+
if (side === DROP_ZONE.ORIGINAL) {
49+
setOriginalText(content);
50+
} else {
51+
setModifiedText(content);
52+
}
53+
};
54+
reader.readAsText(file);
1755
};
1856

1957
const refreshEditor = () => {
@@ -64,27 +102,74 @@ export const TextDiff = ({ className = '' }: TextDiffProps) => {
64102
</div>
65103

66104
{/* Diff Editor */}
67-
<DiffEditor
68-
wrapperProps={{
69-
className: 'flex-1',
70-
}}
71-
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
72-
original={originalText}
73-
modified={modifiedText}
74-
onMount={handleEditorDidMount}
75-
options={{
76-
readOnly: false,
77-
minimap: { enabled: false },
78-
scrollBeyondLastLine: false,
79-
fontSize: 14,
80-
lineNumbers: 'on',
81-
renderSideBySide: true,
82-
enableSplitViewResizing: true,
83-
ignoreTrimWhitespace: false,
84-
renderIndicators: true,
85-
originalEditable: true,
86-
}}
87-
/>
105+
<div className="flex-1 flex flex-col relative">
106+
<DiffEditor
107+
wrapperProps={{
108+
className: 'flex-1',
109+
}}
110+
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
111+
original={originalText}
112+
modified={modifiedText}
113+
onMount={handleEditorDidMount}
114+
options={{
115+
readOnly: false,
116+
minimap: { enabled: false },
117+
scrollBeyondLastLine: false,
118+
fontSize: 14,
119+
lineNumbers: 'on',
120+
enableSplitViewResizing: false,
121+
ignoreTrimWhitespace: false,
122+
renderIndicators: true,
123+
originalEditable: true,
124+
}}
125+
/>
126+
127+
{isDragging && (
128+
<>
129+
<div
130+
className={`absolute top-0 left-0 w-1/2 h-full pointer-events-none transition-all duration-200 ${
131+
activeDropZone === DROP_ZONE.ORIGINAL
132+
? 'bg-blue-500/20 border-2 border-blue-500 border-dashed'
133+
: 'bg-gray-500/10'
134+
}`}
135+
>
136+
{activeDropZone === DROP_ZONE.ORIGINAL && (
137+
<div className="flex items-center justify-center h-full">
138+
<div className="bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
139+
<div className="flex items-center space-x-2">
140+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
141+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
142+
</svg>
143+
<span>Drag to update original content</span>
144+
</div>
145+
</div>
146+
</div>
147+
)}
148+
</div>
149+
150+
<div
151+
className={`absolute top-0 right-0 w-1/2 h-full pointer-events-none transition-all duration-200 ${
152+
activeDropZone === DROP_ZONE.MODIFIED
153+
? 'bg-green-500/20 border-2 border-green-500 border-dashed'
154+
: 'bg-gray-500/10'
155+
}`}
156+
>
157+
{activeDropZone === DROP_ZONE.MODIFIED && (
158+
<div className="flex items-center justify-center h-full">
159+
<div className="bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
160+
<div className="flex items-center space-x-2">
161+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
162+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
163+
</svg>
164+
<span>Drag to update modified content</span>
165+
</div>
166+
</div>
167+
</div>
168+
)}
169+
</div>
170+
</>
171+
)}
172+
</div>
88173
</div>
89174
);
90175
};

src/hooks/useDragAndDrop.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useState, useCallback, useRef, useEffect } from "react";
2+
3+
interface DragHandlers {
4+
handleDragEnter: (e: DragEvent) => void;
5+
handleDragOver: (e: DragEvent) => void;
6+
handleDragLeave: (e: DragEvent) => void;
7+
handleDrop: (e: DragEvent) => void;
8+
}
9+
10+
interface HTMLElementWithDragHandlers extends HTMLElement {
11+
_dragHandlers?: DragHandlers;
12+
}
13+
14+
interface UseDragAndDropOptions<T extends string> {
15+
onFilesDrop?: (files: FileList, dropZone: T) => void;
16+
onDragEnter?: (dropZone: T, e: DragEvent) => void;
17+
onDragOver?: (dropZone: T, e: DragEvent) => void;
18+
onDragLeave?: (dropZone: T, e: DragEvent) => void;
19+
}
20+
21+
/**
22+
* Register multiple drop zones and handle drag and drop events for them.
23+
* @param options - {@link UseDragAndDropOptions}.
24+
* @example
25+
* const { isDragging, activeDropZone, registerDropZone } = useDragAndDrop({
26+
* onFilesDrop: (files, dropZone) => {
27+
* console.log(files, dropZone);
28+
* }
29+
* });
30+
*
31+
* registerDropZone(document.getElementById('drop-zone'), 'drop-zone');
32+
*/
33+
export const useDragAndDrop = <T extends string>(options: UseDragAndDropOptions<T> = {}) => {
34+
const [isDragging, setIsDragging] = useState(false);
35+
const [activeDropZone, setActiveDropZone] = useState<T | null>(null);
36+
const dropZonesRef = useRef<Map<T, HTMLElement>>(new Map());
37+
38+
const { onFilesDrop, onDragEnter, onDragOver, onDragLeave } = options;
39+
40+
const setupDropZone = useCallback((element: HTMLElement, zoneId: T) => {
41+
const handleDragEnter = (e: DragEvent) => {
42+
e.preventDefault();
43+
e.stopPropagation();
44+
setIsDragging(true);
45+
setActiveDropZone(zoneId);
46+
onDragEnter?.(zoneId, e);
47+
};
48+
49+
// must update drag state in drag over, otherwise async event will trigger wrong state
50+
const handleDragOver = (e: DragEvent) => {
51+
e.preventDefault();
52+
e.stopPropagation();
53+
setIsDragging(true);
54+
setActiveDropZone(zoneId);
55+
onDragOver?.(zoneId, e);
56+
};
57+
58+
const handleDragLeave = (e: DragEvent) => {
59+
e.preventDefault();
60+
e.stopPropagation();
61+
setIsDragging(false);
62+
setActiveDropZone(null);
63+
onDragLeave?.(zoneId, e);
64+
};
65+
66+
const handleDrop = (e: DragEvent) => {
67+
e.preventDefault();
68+
e.stopPropagation();
69+
setIsDragging(false);
70+
setActiveDropZone(null);
71+
72+
const files = e.dataTransfer?.files;
73+
if (files && files.length > 0) {
74+
onFilesDrop?.(files, zoneId);
75+
}
76+
};
77+
78+
element.addEventListener('dragenter', handleDragEnter);
79+
element.addEventListener('dragover', handleDragOver);
80+
element.addEventListener('dragleave', handleDragLeave);
81+
element.addEventListener('drop', handleDrop);
82+
83+
// store the event handlers for cleanup
84+
(element as HTMLElementWithDragHandlers)._dragHandlers = {
85+
handleDragEnter,
86+
handleDragOver,
87+
handleDragLeave,
88+
handleDrop
89+
};
90+
}, [onFilesDrop, onDragEnter, onDragOver, onDragLeave]);
91+
92+
const cleanupDropZone = useCallback((element: HTMLElementWithDragHandlers) => {
93+
const handlers = element._dragHandlers;
94+
if (handlers) {
95+
element.removeEventListener('dragenter', handlers.handleDragEnter);
96+
element.removeEventListener('dragover', handlers.handleDragOver);
97+
element.removeEventListener('dragleave', handlers.handleDragLeave);
98+
element.removeEventListener('drop', handlers.handleDrop);
99+
delete element._dragHandlers;
100+
}
101+
}, []);
102+
103+
const registerDropZone = useCallback((element: HTMLElement, zoneId: T) => {
104+
dropZonesRef.current.set(zoneId, element);
105+
setupDropZone(element, zoneId);
106+
}, [setupDropZone]);
107+
108+
const unregisterDropZone = useCallback((zoneId: T) => {
109+
const element = dropZonesRef.current.get(zoneId);
110+
if (element) {
111+
cleanupDropZone(element as HTMLElementWithDragHandlers);
112+
dropZonesRef.current.delete(zoneId);
113+
}
114+
}, [cleanupDropZone]);
115+
116+
// cleanup drop zones
117+
useEffect(() => {
118+
const dropZones = dropZonesRef.current;
119+
120+
return () => {
121+
dropZones.forEach((_, zoneId) => {
122+
unregisterDropZone(zoneId);
123+
});
124+
dropZones.clear();
125+
};
126+
}, [unregisterDropZone]);
127+
128+
return {
129+
isDragging,
130+
activeDropZone,
131+
registerDropZone,
132+
};
133+
};

0 commit comments

Comments
 (0)