Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 57 additions & 50 deletions src/components/NoteManager/components/NoteDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,10 @@ import {
DropdownMenuTrigger,
cn,
} from "@fluffylabs/shared-ui";
import {
type MouseEvent,
type MouseEventHandler,
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from "react";
import { type MouseEvent, useEffect, useRef, useState } from "react";
import { useNoteContext } from "./NoteContext";
import { DropdownMenuItemCopyButton } from "./SimpleComponents/DropdownMenuItemCopyButton";
import { TwoStepDropdownMenuItem } from "./SimpleComponents/TwoStepDropdownMenuItem";

export const NoteDropdown = ({
buttonClassName,
Expand Down Expand Up @@ -57,20 +51,42 @@ export const NoteDropdown = ({
handleEditClick();
};

const contentRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { setIsTracked: setTrackMousePosition, mousePositionRef } = useToggagleableMousePositionTracking(false);

const handleCopyInitiated = () => {
setTrackMousePosition(true);
};

const handleCopyComplete = () => {
const escapeEvent = new KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
keyCode: 27,
bubbles: true,
});
document.dispatchEvent(escapeEvent);
const isMouseOverButton =
buttonRef.current && mousePositionRef.current
? isMouseOverElement(mousePositionRef.current, buttonRef.current)
: false;
const isMouseOverContent =
contentRef.current && mousePositionRef.current
? isMouseOverElement(mousePositionRef.current, contentRef.current)
: false;

const shouldDropdownBeClosed = !isMouseOverButton && !isMouseOverContent;

if (shouldDropdownBeClosed) {
const escapeEvent = new KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
keyCode: 27,
bubbles: true,
});
document.dispatchEvent(escapeEvent);
}
};

return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
ref={buttonRef}
variant="ghost"
intent="neutralMedium"
className={cn("p-2 h-6", buttonClassName)}
Expand All @@ -92,11 +108,15 @@ export const NoteDropdown = ({
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuContent className="w-56" align="end" ref={contentRef}>
<DropdownMenuItem asChild>
<a href={`#${currentVersionLink}`} onClick={handleOpenClose} className="flex justify-between items-center">
<span>Open</span>
<DropdownMenuItemCopyButton href={`/#${currentVersionLink}`} onCopyComplete={handleCopyComplete} />
<DropdownMenuItemCopyButton
href={`/#${currentVersionLink}`}
onCopyComplete={handleCopyComplete}
onCopyInitiated={handleCopyInitiated}
/>
</a>
</DropdownMenuItem>
{!note.current.isUpToDate && (
Expand All @@ -109,7 +129,11 @@ export const NoteDropdown = ({
className="flex justify-between items-center"
>
<span>Open in v{noteOriginalVersionShort}</span>
<DropdownMenuItemCopyButton href={`/#${originalVersionLink}`} onCopyComplete={handleCopyComplete} />
<DropdownMenuItemCopyButton
href={`/#${originalVersionLink}`}
onCopyComplete={handleCopyComplete}
onCopyInitiated={handleCopyInitiated}
/>
</a>
</DropdownMenuItem>
</>
Expand All @@ -130,44 +154,27 @@ export const NoteDropdown = ({
);
};

const TwoStepDropdownMenuItem = ({
children,
confirmationSlot,
onClick,
}: PropsWithChildren<{ confirmationSlot: ReactNode; onClick: MouseEventHandler<HTMLDivElement> }>) => {
const [isConfirmation, setIsConfirmation] = useState(false);
const useToggagleableMousePositionTracking = (initialIsTracked: boolean) => {
const [isTracked, setIsTracked] = useState(initialIsTracked);
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });

useEffect(() => {
if (!isConfirmation) {
return;
if (!isTracked) {
}

const timeoutHandle = setTimeout(() => {
setIsConfirmation(false);
}, 2000);
document.addEventListener("mousemove", (e) => {
mousePositionRef.current = { x: e.clientX, y: e.clientY };
});

return () => {
clearTimeout(timeoutHandle);
document.removeEventListener("mousemove", () => {});
};
}, [isConfirmation]);

const handleOnClick: MouseEventHandler<HTMLDivElement> = (e) => {
if (!isConfirmation) {
e.preventDefault();
e.stopPropagation();
setIsConfirmation(true);
} else {
onClick(e);
}
};
}, [isTracked]);

return (
<DropdownMenuItem
onClick={handleOnClick}
className={cn(isConfirmation ? "text-destructive hover:bg-destructive/20 hover:text-destructive" : "")}
>
{!isConfirmation && children}
{isConfirmation && confirmationSlot}
</DropdownMenuItem>
);
return { isTracked, setIsTracked, mousePositionRef };
};

const isMouseOverElement = (mousePos: { x: number; y: number }, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
return mousePos.x >= rect.left && mousePos.x <= rect.right && mousePos.y >= rect.top && mousePos.y <= rect.bottom;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { useEffect, useRef, useState } from "react";
import { CheckIcon } from "../icons/CheckIcon";
import { CopyIcon } from "../icons/CopyIcon";

export const DropdownMenuItemCopyButton = ({ href, onCopyComplete }: { href: string; onCopyComplete: () => void }) => {
export const DropdownMenuItemCopyButton = ({
href,
onCopyComplete,
onCopyInitiated,
}: { href: string; onCopyComplete: () => void; onCopyInitiated: () => void }) => {
const [secondaryState, setSecondaryState] = useState<"success" | "error" | undefined>(undefined);
const onCopyCompleteRef = useRef(onCopyComplete);
onCopyCompleteRef.current = onCopyComplete;
Expand All @@ -23,26 +27,29 @@ export const DropdownMenuItemCopyButton = ({ href, onCopyComplete }: { href: str
};
}, [secondaryState]);

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();

if (!secondaryState) {
try {
onCopyInitiated();
navigator.clipboard.writeText(`${window.location.origin}${href}`);
setSecondaryState("success");
} catch (error) {
setSecondaryState("error");
console.error("Failed to copy link:", error);
}
}
};

return (
<Button
variant="ghost"
size="icon"
aria-label="Copy link to clipboard"
disabled={secondaryState !== undefined}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();

if (!secondaryState) {
try {
navigator.clipboard.writeText(`${window.location.origin}${href}`);
setSecondaryState("success");
} catch (error) {
setSecondaryState("error");
console.error("Failed to copy link:", error);
}
}
}}
onClick={handleClick}
className="py-3.5 px-3.5 my-[-8px]"
>
{!secondaryState && <CopyIcon />}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { cn } from "@fluffylabs/shared-ui";
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
import { type MouseEventHandler, type PropsWithChildren, type ReactNode, useEffect, useRef, useState } from "react";

export const TwoStepDropdownMenuItem = ({
children,
confirmationSlot,
onClick,
}: PropsWithChildren<{ confirmationSlot: ReactNode; onClick: MouseEventHandler<HTMLDivElement> }>) => {
const [isConfirmation, setIsConfirmation] = useBooleanStateWithAutoRevertToFalse({ delayInMs: 2000 });

const handleOnClick: MouseEventHandler<HTMLDivElement> = (e) => {
if (!isConfirmation) {
e.preventDefault();
e.stopPropagation();
setIsConfirmation(true);
} else {
onClick(e);
}
};

return (
<DropdownMenuItem
onClick={handleOnClick}
className={cn(isConfirmation ? "text-destructive hover:bg-destructive/20 hover:text-destructive" : "")}
>
{!isConfirmation && children}
{isConfirmation && confirmationSlot}
</DropdownMenuItem>
);
};

function useBooleanStateWithAutoRevertToFalse({ delayInMs }: { delayInMs: number }) {
const [state, setState] = useState(false);
const delayInMsRef = useRef(delayInMs);
delayInMsRef.current = delayInMs;

useEffect(() => {
if (!state) {
return;
}

const timeoutHandle = setTimeout(() => {
setState(false);
}, delayInMsRef.current);

return () => {
clearTimeout(timeoutHandle);
};
}, [state]);

return [state, setState] as const;
}
Loading