Skip to content

Commit be4fd5c

Browse files
Feature: right click to open new menu, or right click file/folder to open file/folder menu
1 parent 2ee9afb commit be4fd5c

4 files changed

Lines changed: 315 additions & 4 deletions

File tree

app/components/ContextMenu.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
import type { ElementType } from "react";
5+
6+
export interface ContextMenuAction {
7+
label: string;
8+
icon: ElementType;
9+
onClick: () => void;
10+
danger?: boolean;
11+
disabled?: boolean;
12+
}
13+
14+
interface ContextMenuProps {
15+
position: { x: number; y: number };
16+
actions: ContextMenuAction[];
17+
onClose: () => void;
18+
}
19+
20+
export default function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
21+
const menuRef = useRef<HTMLDivElement>(null);
22+
const [menuPosition, setMenuPosition] = useState(position);
23+
24+
useEffect(() => {
25+
const adjustPosition = () => {
26+
const menuWidth = menuRef.current?.offsetWidth ?? 200;
27+
const menuHeight = menuRef.current?.offsetHeight ?? actions.length * 40;
28+
29+
let top = position.y;
30+
let left = position.x;
31+
32+
if (top + menuHeight > window.innerHeight) {
33+
top = Math.max(0, window.innerHeight - menuHeight - 8);
34+
}
35+
if (left + menuWidth > window.innerWidth) {
36+
left = Math.max(0, window.innerWidth - menuWidth - 8);
37+
}
38+
39+
setMenuPosition({ x: left, y: top });
40+
};
41+
42+
adjustPosition();
43+
}, [position, actions.length]);
44+
45+
useEffect(() => {
46+
const handleScroll = () => onClose();
47+
const handleResize = () => onClose();
48+
49+
window.addEventListener("scroll", handleScroll, true);
50+
window.addEventListener("resize", handleResize);
51+
52+
return () => {
53+
window.removeEventListener("scroll", handleScroll, true);
54+
window.removeEventListener("resize", handleResize);
55+
};
56+
}, [onClose]);
57+
58+
return (
59+
<div
60+
ref={menuRef}
61+
className="fixed z-[999] w-52 rounded-lg border border-gray-200 bg-white shadow-lg"
62+
style={{ top: menuPosition.y, left: menuPosition.x }}
63+
role="menu"
64+
onClick={(e) => e.stopPropagation()}
65+
onContextMenu={(e) => e.preventDefault()}
66+
>
67+
{actions.map((action) => {
68+
const Icon = action.icon;
69+
return (
70+
<button
71+
key={action.label}
72+
type="button"
73+
onClick={(e) => {
74+
e.stopPropagation();
75+
if (action.disabled) return;
76+
action.onClick();
77+
}}
78+
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors ${
79+
action.danger
80+
? "text-red-600 hover:bg-red-50"
81+
: action.disabled
82+
? "text-gray-400 cursor-not-allowed"
83+
: "text-gray-700 hover:bg-gray-100"
84+
}`}
85+
role="menuitem"
86+
disabled={action.disabled}
87+
>
88+
<Icon
89+
className={`h-5 w-5 ${
90+
action.danger ? "text-red-500" : action.disabled ? "text-gray-300" : "text-gray-500"
91+
}`}
92+
/>
93+
<span>{action.label}</span>
94+
</button>
95+
);
96+
})}
97+
</div>
98+
);
99+
}
100+
101+

app/components/FileItem.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface FileItemProps {
2828
onDownload?: (file: FileItemData) => void;
2929
onDelete?: (file: FileItemData) => void;
3030
isSelected?: boolean;
31+
onContextMenu?: (file: FileItemData, event: React.MouseEvent) => void;
3132
}
3233

3334
export default function FileItem({
@@ -42,6 +43,7 @@ export default function FileItem({
4243
onDownload,
4344
onDelete,
4445
isSelected = false,
46+
onContextMenu,
4547
}: FileItemProps) {
4648
const [isHovered, setIsHovered] = useState(false);
4749
const clickCountRef = useRef(0);
@@ -128,6 +130,11 @@ export default function FileItem({
128130
role="button"
129131
tabIndex={0}
130132
aria-label={`${file.type === "folder" ? "Folder" : "File"}: ${file.name}`}
133+
onContextMenu={(event) => {
134+
event.preventDefault();
135+
event.stopPropagation();
136+
onContextMenu?.(file, event);
137+
}}
131138
>
132139
{isHovered && (
133140
<FileItemMenu
@@ -164,6 +171,11 @@ export default function FileItem({
164171
role="button"
165172
tabIndex={0}
166173
aria-label={`${file.type === "folder" ? "Folder" : "File"}: ${file.name}`}
174+
onContextMenu={(event) => {
175+
event.preventDefault();
176+
event.stopPropagation();
177+
onContextMenu?.(file, event);
178+
}}
167179
>
168180
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center sm:h-10 sm:w-10">
169181
{getFileIcon(file.type, file.mimeType)}

app/components/FileList.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface FileListProps {
1717
onFileDownload?: (file: FileItemData) => void;
1818
onFileDelete?: (file: FileItemData) => void;
1919
selectedFileIds: string[];
20+
onFileContextMenu?: (file: FileItemData, event: React.MouseEvent) => void;
2021
}
2122

2223
const VIEW_STORAGE_KEY = "solid-file-manager-view";
@@ -33,6 +34,7 @@ export default function FileList({
3334
onFileDownload,
3435
onFileDelete,
3536
selectedFileIds,
37+
onFileContextMenu,
3638
}: FileListProps) {
3739
const [view, setView] = useState<"grid" | "list">(() => {
3840
if (typeof window === "undefined") return "list";
@@ -72,6 +74,7 @@ export default function FileList({
7274
onDownload={onFileDownload}
7375
onDelete={onFileDelete}
7476
isSelected={selectedFileIds.includes(file.id)}
77+
onContextMenu={onFileContextMenu}
7578
/>
7679
))}
7780
</div>
@@ -91,6 +94,7 @@ export default function FileList({
9194
onDownload={onFileDownload}
9295
onDelete={onFileDelete}
9396
isSelected={selectedFileIds.includes(file.id)}
97+
onContextMenu={onFileContextMenu}
9498
/>
9599
))}
96100
</div>

0 commit comments

Comments
 (0)