Skip to content

Commit 3ce65be

Browse files
authored
upgrade DropdowmMenuCopyButton by closing dropdown conditionally (#346)
feat: upgrade DropdowmMenuCopyButton by closing dropdown conditionally
1 parent 8073d20 commit 3ce65be

File tree

3 files changed

+137
-68
lines changed

3 files changed

+137
-68
lines changed

src/components/NoteManager/components/NoteDropdown.tsx

Lines changed: 63 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,10 @@ import {
77
DropdownMenuTrigger,
88
cn,
99
} from "@fluffylabs/shared-ui";
10-
import {
11-
type MouseEvent,
12-
type MouseEventHandler,
13-
type PropsWithChildren,
14-
type ReactNode,
15-
useEffect,
16-
useState,
17-
} from "react";
10+
import { type MouseEvent as ReactMouseEvent, useEffect, useRef, useState } from "react";
1811
import { useNoteContext } from "./NoteContext";
1912
import { DropdownMenuItemCopyButton } from "./SimpleComponents/DropdownMenuItemCopyButton";
13+
import { TwoStepDropdownMenuItem } from "./SimpleComponents/TwoStepDropdownMenuItem";
2014

2115
export const NoteDropdown = ({
2216
buttonClassName,
@@ -34,43 +28,65 @@ export const NoteDropdown = ({
3428
originalVersionLink,
3529
} = useNoteContext();
3630

37-
const handleOpenClose = (e: MouseEvent<HTMLAnchorElement>) => {
31+
const handleOpenClose = (e: ReactMouseEvent<HTMLAnchorElement>) => {
3832
e.stopPropagation();
3933
handleSelectNote({ type: active ? "close" : "currentVersion" });
4034
};
4135

42-
const openInDifferentVersion = (e: MouseEvent<HTMLAnchorElement>) => {
36+
const openInDifferentVersion = (e: ReactMouseEvent<HTMLAnchorElement>) => {
4337
e.stopPropagation();
4438
handleSelectNote({ type: "originalVersion" });
4539
};
4640

47-
const removeNote = (e: MouseEvent<HTMLDivElement>) => {
41+
const removeNote = (e: ReactMouseEvent<HTMLDivElement>) => {
4842
e.stopPropagation();
4943
onDelete?.();
5044
};
5145

52-
const editNode = (e: MouseEvent<HTMLDivElement>) => {
46+
const editNode = (e: ReactMouseEvent<HTMLDivElement>) => {
5347
e.stopPropagation();
5448
if (!active) {
5549
handleSelectNote();
5650
}
5751
handleEditClick();
5852
};
5953

54+
const contentRef = useRef<HTMLDivElement>(null);
55+
const buttonRef = useRef<HTMLButtonElement>(null);
56+
const { setIsTracked: setTrackMousePosition, mousePositionRef } = useToggleableMousePositionTracking(false);
57+
58+
const handleCopyInitiated = () => {
59+
setTrackMousePosition(true);
60+
};
61+
6062
const handleCopyComplete = () => {
61-
const escapeEvent = new KeyboardEvent("keydown", {
62-
key: "Escape",
63-
code: "Escape",
64-
keyCode: 27,
65-
bubbles: true,
66-
});
67-
document.dispatchEvent(escapeEvent);
63+
const isMouseOverButton =
64+
buttonRef.current && mousePositionRef.current
65+
? isMouseOverElement(mousePositionRef.current, buttonRef.current)
66+
: false;
67+
const isMouseOverContent =
68+
contentRef.current && mousePositionRef.current
69+
? isMouseOverElement(mousePositionRef.current, contentRef.current)
70+
: false;
71+
72+
const shouldDropdownBeClosed = !isMouseOverButton && !isMouseOverContent;
73+
74+
if (shouldDropdownBeClosed) {
75+
const escapeEvent = new KeyboardEvent("keydown", {
76+
key: "Escape",
77+
code: "Escape",
78+
keyCode: 27,
79+
bubbles: true,
80+
});
81+
document.dispatchEvent(escapeEvent);
82+
}
6883
};
6984

7085
return (
7186
<DropdownMenu onOpenChange={onOpenChange}>
7287
<DropdownMenuTrigger asChild>
7388
<Button
89+
ref={buttonRef}
7490
variant="ghost"
7591
intent="neutralMedium"
7692
className={cn("p-2 h-6", buttonClassName)}
@@ -92,11 +108,15 @@ export const NoteDropdown = ({
92108
</svg>
93109
</Button>
94110
</DropdownMenuTrigger>
95-
<DropdownMenuContent className="w-56" align="end">
111+
<DropdownMenuContent className="w-56" align="end" ref={contentRef}>
96112
<DropdownMenuItem asChild>
97113
<a href={`#${currentVersionLink}`} onClick={handleOpenClose} className="flex justify-between items-center">
98114
<span>Open</span>
99-
<DropdownMenuItemCopyButton href={`/#${currentVersionLink}`} onCopyComplete={handleCopyComplete} />
115+
<DropdownMenuItemCopyButton
116+
href={`/#${currentVersionLink}`}
117+
onCopyComplete={handleCopyComplete}
118+
onCopyInitiated={handleCopyInitiated}
119+
/>
100120
</a>
101121
</DropdownMenuItem>
102122
{!note.current.isUpToDate && (
@@ -109,7 +129,11 @@ export const NoteDropdown = ({
109129
className="flex justify-between items-center"
110130
>
111131
<span>Open in v{noteOriginalVersionShort}</span>
112-
<DropdownMenuItemCopyButton href={`/#${originalVersionLink}`} onCopyComplete={handleCopyComplete} />
132+
<DropdownMenuItemCopyButton
133+
href={`/#${originalVersionLink}`}
134+
onCopyComplete={handleCopyComplete}
135+
onCopyInitiated={handleCopyInitiated}
136+
/>
113137
</a>
114138
</DropdownMenuItem>
115139
</>
@@ -130,44 +154,30 @@ export const NoteDropdown = ({
130154
);
131155
};
132156

133-
const TwoStepDropdownMenuItem = ({
134-
children,
135-
confirmationSlot,
136-
onClick,
137-
}: PropsWithChildren<{ confirmationSlot: ReactNode; onClick: MouseEventHandler<HTMLDivElement> }>) => {
138-
const [isConfirmation, setIsConfirmation] = useState(false);
157+
const useToggleableMousePositionTracking = (initialIsTracked: boolean) => {
158+
const [isTracked, setIsTracked] = useState(initialIsTracked);
159+
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
139160

140161
useEffect(() => {
141-
if (!isConfirmation) {
162+
if (!isTracked) {
142163
return;
143164
}
144165

145-
const timeoutHandle = setTimeout(() => {
146-
setIsConfirmation(false);
147-
}, 2000);
166+
const handler = (e: MouseEvent) => {
167+
mousePositionRef.current = { x: e.clientX, y: e.clientY };
168+
};
169+
170+
document.addEventListener("mousemove", handler);
148171

149172
return () => {
150-
clearTimeout(timeoutHandle);
173+
document.removeEventListener("mousemove", handler);
151174
};
152-
}, [isConfirmation]);
153-
154-
const handleOnClick: MouseEventHandler<HTMLDivElement> = (e) => {
155-
if (!isConfirmation) {
156-
e.preventDefault();
157-
e.stopPropagation();
158-
setIsConfirmation(true);
159-
} else {
160-
onClick(e);
161-
}
162-
};
175+
}, [isTracked]);
163176

164-
return (
165-
<DropdownMenuItem
166-
onClick={handleOnClick}
167-
className={cn(isConfirmation ? "text-destructive hover:bg-destructive/20 hover:text-destructive" : "")}
168-
>
169-
{!isConfirmation && children}
170-
{isConfirmation && confirmationSlot}
171-
</DropdownMenuItem>
172-
);
177+
return { isTracked, setIsTracked, mousePositionRef };
178+
};
179+
180+
const isMouseOverElement = (mousePos: { x: number; y: number }, element: HTMLElement) => {
181+
const rect = element.getBoundingClientRect();
182+
return mousePos.x >= rect.left && mousePos.x <= rect.right && mousePos.y >= rect.top && mousePos.y <= rect.bottom;
173183
};

src/components/NoteManager/components/SimpleComponents/DropdownMenuItemCopyButton.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { useEffect, useRef, useState } from "react";
33
import { CheckIcon } from "../icons/CheckIcon";
44
import { CopyIcon } from "../icons/CopyIcon";
55

6-
export const DropdownMenuItemCopyButton = ({ href, onCopyComplete }: { href: string; onCopyComplete: () => void }) => {
6+
export const DropdownMenuItemCopyButton = ({
7+
href,
8+
onCopyComplete,
9+
onCopyInitiated,
10+
}: { href: string; onCopyComplete: () => void; onCopyInitiated: () => void }) => {
711
const [secondaryState, setSecondaryState] = useState<"success" | "error" | undefined>(undefined);
812
const onCopyCompleteRef = useRef(onCopyComplete);
913
onCopyCompleteRef.current = onCopyComplete;
@@ -23,26 +27,29 @@ export const DropdownMenuItemCopyButton = ({ href, onCopyComplete }: { href: str
2327
};
2428
}, [secondaryState]);
2529

30+
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
31+
e.preventDefault();
32+
e.stopPropagation();
33+
34+
if (!secondaryState) {
35+
try {
36+
onCopyInitiated();
37+
await navigator.clipboard.writeText(`${window.location.origin}${href}`);
38+
setSecondaryState("success");
39+
} catch (error) {
40+
setSecondaryState("error");
41+
console.error("Failed to copy link:", error);
42+
}
43+
}
44+
};
45+
2646
return (
2747
<Button
2848
variant="ghost"
2949
size="icon"
3050
aria-label="Copy link to clipboard"
3151
disabled={secondaryState !== undefined}
32-
onClick={(e) => {
33-
e.preventDefault();
34-
e.stopPropagation();
35-
36-
if (!secondaryState) {
37-
try {
38-
navigator.clipboard.writeText(`${window.location.origin}${href}`);
39-
setSecondaryState("success");
40-
} catch (error) {
41-
setSecondaryState("error");
42-
console.error("Failed to copy link:", error);
43-
}
44-
}
45-
}}
52+
onClick={handleClick}
4653
className="py-3.5 px-3.5 my-[-8px]"
4754
>
4855
{!secondaryState && <CopyIcon />}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { DropdownMenuItem, cn } from "@fluffylabs/shared-ui";
2+
import { type MouseEventHandler, type PropsWithChildren, type ReactNode, useEffect, useRef, useState } from "react";
3+
4+
export const TwoStepDropdownMenuItem = ({
5+
children,
6+
confirmationSlot,
7+
onClick,
8+
}: PropsWithChildren<{ confirmationSlot: ReactNode; onClick: MouseEventHandler<HTMLDivElement> }>) => {
9+
const [isConfirmation, setIsConfirmation] = useBooleanStateWithAutoRevertToFalse({ delayInMs: 2000 });
10+
11+
const handleOnClick: MouseEventHandler<HTMLDivElement> = (e) => {
12+
if (!isConfirmation) {
13+
e.preventDefault();
14+
e.stopPropagation();
15+
setIsConfirmation(true);
16+
} else {
17+
onClick(e);
18+
}
19+
};
20+
21+
return (
22+
<DropdownMenuItem
23+
onClick={handleOnClick}
24+
className={cn(isConfirmation ? "text-destructive hover:bg-destructive/20 hover:text-destructive" : "")}
25+
>
26+
{!isConfirmation && children}
27+
{isConfirmation && confirmationSlot}
28+
</DropdownMenuItem>
29+
);
30+
};
31+
32+
function useBooleanStateWithAutoRevertToFalse({ delayInMs }: { delayInMs: number }) {
33+
const [state, setState] = useState(false);
34+
const delayInMsRef = useRef(delayInMs);
35+
delayInMsRef.current = delayInMs;
36+
37+
useEffect(() => {
38+
if (!state) {
39+
return;
40+
}
41+
42+
const timeoutHandle = setTimeout(() => {
43+
setState(false);
44+
}, delayInMsRef.current);
45+
46+
return () => {
47+
clearTimeout(timeoutHandle);
48+
};
49+
}, [state]);
50+
51+
return [state, setState] as const;
52+
}

0 commit comments

Comments
 (0)