Skip to content

Commit 8a16a78

Browse files
authored
fix: prevent overlay flicker during drag-and-drop (#129)
1 parent 90a0e3c commit 8a16a78

25 files changed

+185
-150
lines changed

src/components/ast/ast-view-mode.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useExplorer } from "@/hooks/use-explorer";
44
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
55
import { astViewOptions } from "@/lib/const";
6-
import { cn } from "@/lib/utils";
6+
import { mergeClassNames } from "@/lib/utils";
77
import type { FC } from "react";
88

99
export const AstViewMode: FC = () => {
@@ -29,7 +29,7 @@ export const AstViewMode: FC = () => {
2929
<ToggleGroupItem
3030
key={option.value}
3131
value={option.value}
32-
className={cn(
32+
className={mergeClassNames(
3333
"border -m-px flex items-center gap-1.5",
3434
option.value === astView
3535
? "!bg-background"

src/components/ast/css-ast-tree-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from "@/components/ui/accordion";
66
import { TreeEntry } from "../tree-entry";
77
import type { FC } from "react";
8-
import { cn } from "@/lib/utils";
8+
import { mergeClassNames } from "@/lib/utils";
99

1010
type ASTNode = {
1111
readonly type: string;
@@ -28,7 +28,7 @@ export const CssAstTreeItem: FC<CssAstTreeItemProperties> = ({
2828
return (
2929
<AccordionItem
3030
value={`${index}-${data.type}`}
31-
className={cn(
31+
className={mergeClassNames(
3232
"border border-card rounded-lg overflow-hidden",
3333
isEsqueryMatchedNode && "border-primary border-4",
3434
)}

src/components/ast/html-ast-tree-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from "@/components/ui/accordion";
66
import { TreeEntry } from "../tree-entry";
77
import type { FC } from "react";
8-
import { cn } from "@/lib/utils";
8+
import { mergeClassNames } from "@/lib/utils";
99

1010
type ASTNode = {
1111
readonly type: string;
@@ -28,7 +28,7 @@ export const HtmlAstTreeItem: FC<HtmlAstTreeItemProperties> = ({
2828
return (
2929
<AccordionItem
3030
value={`${index}-${data.type}`}
31-
className={cn(
31+
className={mergeClassNames(
3232
"border border-card rounded-lg overflow-hidden",
3333
isEsqueryMatchedNode && "border-primary border-4",
3434
)}

src/components/ast/javascript-ast-tree-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import { TreeEntry } from "../tree-entry";
77
import type { FC } from "react";
88
import type * as espree from "espree";
9-
import { cn } from "@/lib/utils";
9+
import { mergeClassNames } from "@/lib/utils";
1010

1111
export type JavascriptAstTreeItemProperties = {
1212
readonly index: number;
@@ -26,7 +26,7 @@ export const JavascriptAstTreeItem: FC<JavascriptAstTreeItemProperties> = ({
2626
return (
2727
<AccordionItem
2828
value={`${index}-${data.type}`}
29-
className={cn(
29+
className={mergeClassNames(
3030
"border border-card rounded-lg overflow-hidden",
3131
isEsqueryMatchedNode && "border-primary border-4",
3232
)}

src/components/ast/json-ast-tree-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from "@/components/ui/accordion";
66
import { TreeEntry } from "../tree-entry";
77
import type { FC } from "react";
8-
import { cn } from "@/lib/utils";
8+
import { mergeClassNames } from "@/lib/utils";
99

1010
type ASTNode = {
1111
readonly type: string;
@@ -28,7 +28,7 @@ export const JsonAstTreeItem: FC<JsonAstTreeItemProperties> = ({
2828
return (
2929
<AccordionItem
3030
value={`${index}-${data.type}`}
31-
className={cn(
31+
className={mergeClassNames(
3232
"border border-card rounded-lg overflow-hidden",
3333
isEsqueryMatchedNode && "border-primary border-4",
3434
)}

src/components/ast/markdown-ast-tree-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from "@/components/ui/accordion";
66
import { TreeEntry } from "../tree-entry";
77
import type { FC } from "react";
8-
import { cn } from "@/lib/utils";
8+
import { mergeClassNames } from "@/lib/utils";
99

1010
type ASTNode = {
1111
readonly type: string;
@@ -28,7 +28,7 @@ export const MarkdownAstTreeItem: FC<MarkdownAstTreeItemProperties> = ({
2828
return (
2929
<AccordionItem
3030
value={`${index}-${data.type}`}
31-
className={cn(
31+
className={mergeClassNames(
3232
"border border-card rounded-lg overflow-hidden",
3333
isEsqueryMatchedNode && "border-primary border-4",
3434
)}

src/components/editor.tsx

Lines changed: 82 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
"use client";
22

33
import { useEffect, useRef, useState, FC, useMemo } from "react";
4-
import { useExplorer } from "@/hooks/use-explorer";
4+
import { useExplorer, type Language } from "@/hooks/use-explorer";
55
import CodeMirror from "@uiw/react-codemirror";
66
import { json } from "@codemirror/lang-json";
77
import { javascript } from "@codemirror/lang-javascript";
88
import { markdown } from "@codemirror/lang-markdown";
99
import { css } from "@codemirror/lang-css";
1010
import { html } from "@codemirror/lang-html";
1111
import { EditorView } from "@codemirror/view";
12-
import { EditorState } from "@codemirror/state";
13-
import clsx from "clsx";
1412
import { LanguageSupport } from "@codemirror/language";
15-
import { debounce } from "../lib/utils";
13+
import { mergeClassNames, debounce } from "@/lib/utils";
1614
import {
1715
ESLintPlaygroundTheme,
1816
ESLintPlaygroundHighlightStyle,
@@ -22,14 +20,16 @@ import {
2220
type HighlightedRange,
2321
} from "@/utils/highlighted-ranges";
2422

25-
const languageExtensions: Record<string, (isJSX?: boolean) => LanguageSupport> =
26-
{
27-
javascript: (isJSX: boolean = false) => javascript({ jsx: isJSX }),
28-
json: () => json(),
29-
markdown: () => markdown(),
30-
css: () => css(),
31-
html: () => html(),
32-
};
23+
const languageExtensions: Record<
24+
Language,
25+
(isJSX?: boolean) => LanguageSupport
26+
> = {
27+
javascript: (isJSX: boolean = false) => javascript({ jsx: isJSX }),
28+
json: () => json(),
29+
markdown: () => markdown(),
30+
css: () => css(),
31+
html: () => html(),
32+
};
3333

3434
type EditorProperties = {
3535
readOnly?: boolean;
@@ -48,22 +48,23 @@ export const Editor: FC<EditorProperties> = ({
4848
const { isJSX } = jsOptions;
4949
const [isDragOver, setIsDragOver] = useState<boolean>(false);
5050
const editorContainerRef = useRef<HTMLDivElement | null>(null);
51-
const dropMessageRef = useRef<HTMLDivElement | null>(null);
52-
53-
const activeLanguageExtension = readOnly
54-
? languageExtensions.json()
55-
: languageExtensions[language]
56-
? languageExtensions[language](isJSX)
57-
: [];
58-
59-
const editorExtensions = [
60-
activeLanguageExtension,
61-
wrap ? EditorView.lineWrapping : [],
62-
readOnly ? EditorState.readOnly.of(true) : [],
63-
ESLintPlaygroundTheme,
64-
ESLintPlaygroundHighlightStyle,
65-
highlightedRangesExtension(highlightedRanges),
66-
];
51+
const dragDepthRef = useRef(0);
52+
53+
const activeLanguageExtension = useMemo<LanguageSupport>(() => {
54+
if (readOnly) return languageExtensions.json();
55+
return languageExtensions[language](isJSX);
56+
}, [readOnly, language, isJSX]);
57+
58+
const editorExtensions = useMemo(
59+
() => [
60+
activeLanguageExtension,
61+
wrap ? EditorView.lineWrapping : [],
62+
ESLintPlaygroundTheme,
63+
ESLintPlaygroundHighlightStyle,
64+
highlightedRangesExtension(highlightedRanges),
65+
],
66+
[activeLanguageExtension, wrap, highlightedRanges],
67+
);
6768

6869
const debouncedOnChange = useMemo(
6970
() =>
@@ -73,93 +74,104 @@ export const Editor: FC<EditorProperties> = ({
7374
[onChange],
7475
);
7576

77+
useEffect(() => {
78+
return () => {
79+
debouncedOnChange.cancel();
80+
};
81+
}, [debouncedOnChange]);
82+
7683
useEffect(() => {
7784
if (readOnly) return;
7885

7986
const editorContainer = editorContainerRef.current;
80-
const dropMessageDiv = dropMessageRef.current;
87+
88+
const isFileDrag = (event: DragEvent) =>
89+
event.dataTransfer?.types.includes("Files");
90+
91+
const handleDragEnter = (event: DragEvent) => {
92+
if (!isFileDrag(event)) return;
93+
dragDepthRef.current += 1;
94+
setIsDragOver(true);
95+
};
8196

8297
const handleDragOver = (event: DragEvent) => {
8398
event.preventDefault();
99+
if (!isFileDrag(event)) return;
84100
setIsDragOver(true);
85101
};
86102

87103
const handleDragLeave = () => {
88-
setIsDragOver(false);
104+
if (dragDepthRef.current > 0) {
105+
dragDepthRef.current -= 1;
106+
}
107+
if (dragDepthRef.current <= 0) {
108+
dragDepthRef.current = 0;
109+
setIsDragOver(false);
110+
}
89111
};
90112

91113
const handleDrop = async (event: DragEvent) => {
92114
event.preventDefault();
115+
dragDepthRef.current = 0;
93116
setIsDragOver(false);
94117

95118
const files = event.dataTransfer?.files;
96119
if (files?.length) {
97120
const file = files[0];
98121
const text = await file.text();
99-
if (editorContainer) {
100-
const editor: HTMLDivElement | null =
101-
editorContainer.querySelector(".cm-content");
102-
if (editor) {
103-
editor.innerText = text;
104-
}
105-
}
122+
onChange?.(text);
106123
}
107124
};
108125

126+
const handleDragEnd = () => {
127+
dragDepthRef.current = 0;
128+
setIsDragOver(false);
129+
};
130+
131+
editorContainer?.addEventListener("dragenter", handleDragEnter);
109132
editorContainer?.addEventListener("dragover", handleDragOver);
110133
editorContainer?.addEventListener("dragleave", handleDragLeave);
111134
editorContainer?.addEventListener("drop", handleDrop);
135+
window.addEventListener("dragend", handleDragEnd);
112136

113-
if (dropMessageDiv) {
114-
dropMessageDiv.addEventListener("dragover", handleDragOver);
115-
dropMessageDiv.addEventListener("dragleave", handleDragLeave);
116-
dropMessageDiv.addEventListener("drop", handleDrop);
117-
}
137+
// Prevent navigation when dropping files outside the editor
138+
const preventWindowNav = (event: DragEvent) => {
139+
event.preventDefault();
140+
};
141+
window.addEventListener("dragover", preventWindowNav);
142+
window.addEventListener("drop", preventWindowNav);
118143

119144
return () => {
145+
editorContainer?.removeEventListener("dragenter", handleDragEnter);
120146
editorContainer?.removeEventListener("dragover", handleDragOver);
121147
editorContainer?.removeEventListener("dragleave", handleDragLeave);
122148
editorContainer?.removeEventListener("drop", handleDrop);
123-
124-
if (dropMessageDiv) {
125-
dropMessageDiv.removeEventListener("dragover", handleDragOver);
126-
dropMessageDiv.removeEventListener(
127-
"dragleave",
128-
handleDragLeave,
129-
);
130-
dropMessageDiv.removeEventListener("drop", handleDrop);
131-
}
149+
window.removeEventListener("dragend", handleDragEnd);
150+
window.removeEventListener("dragover", preventWindowNav);
151+
window.removeEventListener("drop", preventWindowNav);
132152
};
133-
}, [readOnly]);
153+
}, [onChange, readOnly]);
134154

135-
const editorClasses = clsx("relative", {
155+
const editorClasses = mergeClassNames("relative", {
136156
"h-[calc(100%-72px)]": readOnly,
137157
"h-[calc(100%-57px)]": !readOnly,
138158
});
139159

140-
const dropMessageClasses = clsx(
141-
"absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-dropMessage text-white p-2 rounded-lg z-10",
142-
{
143-
flex: isDragOver,
144-
hidden: !isDragOver,
145-
},
146-
);
147-
148-
const dropAreaClass = clsx(
149-
"absolute top-1/2 h-full w-full left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white p-2 z-10",
160+
const dropAreaClass = mergeClassNames(
161+
"absolute inset-0 z-10 pointer-events-none flex items-center justify-center transition-opacity duration-150 bg-dropContainer",
150162
{
151-
"bg-dropContainer": isDragOver,
152-
"bg-transparent": !isDragOver,
153-
flex: isDragOver,
154-
hidden: !isDragOver,
163+
"opacity-0": !isDragOver,
155164
},
156165
);
157166

158167
return (
159168
<div ref={editorContainerRef} className={editorClasses}>
160169
{!readOnly && (
161-
<div ref={dropMessageRef} className={dropAreaClass}>
162-
<div className={dropMessageClasses}>
170+
<div className={dropAreaClass}>
171+
<div
172+
className="bg-dropMessage text-white p-2 rounded-lg"
173+
role="status"
174+
>
163175
Drop here to read file
164176
</div>
165177
</div>
@@ -168,7 +180,7 @@ export const Editor: FC<EditorProperties> = ({
168180
className="h-full overflow-auto scrollbar-thumb scrollbar-track text-sm"
169181
value={value}
170182
extensions={editorExtensions}
171-
onChange={value => debouncedOnChange(value)}
183+
onChange={debouncedOnChange}
172184
readOnly={readOnly}
173185
/>
174186
</div>

src/components/esquery-selector-input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Label } from "@/components/ui/label";
33
import { TextField } from "@/components/ui/text-field";
44
import { useExplorer } from "@/hooks/use-explorer";
55
import { useAST } from "@/hooks/use-ast";
6-
import { cn } from "@/lib/utils";
6+
import { mergeClassNames } from "@/lib/utils";
77
import { esquerySelectorPlaceholder } from "@/lib/const";
88

99
export const EsquerySelectorInput: FC = () => {
@@ -23,7 +23,7 @@ export const EsquerySelectorInput: FC = () => {
2323
<TextField
2424
id={htmlId}
2525
placeholder={esquerySelectorPlaceholder[language]}
26-
className={cn(
26+
className={mergeClassNames(
2727
"flex-1",
2828
!astParseResult.ok ||
2929
(highlightAsNotMatching &&

src/components/path/path-view-mode.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useExplorer } from "@/hooks/use-explorer";
44
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
55
import { pathViewOptions } from "@/lib/const";
6-
import { cn } from "@/lib/utils";
6+
import { mergeClassNames } from "@/lib/utils";
77
import type { FC } from "react";
88

99
export const PathViewMode: FC = () => {
@@ -30,7 +30,7 @@ export const PathViewMode: FC = () => {
3030
<ToggleGroupItem
3131
key={option.value}
3232
value={option.value}
33-
className={cn(
33+
className={mergeClassNames(
3434
"border -m-px flex items-center gap-1.5",
3535
option.value === pathView
3636
? "!bg-background"

0 commit comments

Comments
 (0)