From bcd776259460d4bac52208beca84201fcbcb3e4a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 15 Oct 2025 14:05:32 +0100 Subject: [PATCH] Replace kebab menu in file editor with on hover menu (#4624) Replace kebab menu in file editor with on hover menu by refactoring page editor's menu into a new component. In mobile sizes, the hover menus are always visible. --- .../public/locales/en-GB/translation.json | 5 + .../fileEditor/FileEditor.module.css | 2 +- .../fileEditor/FileEditorThumbnail.tsx | 227 ++++++++---------- .../components/pageEditor/PageThumbnail.tsx | 202 ++++++---------- .../shared/HoverActionMenu.module.css | 28 +++ .../src/components/shared/HoverActionMenu.tsx | 60 +++++ 6 files changed, 270 insertions(+), 254 deletions(-) create mode 100644 frontend/src/components/shared/HoverActionMenu.module.css create mode 100644 frontend/src/components/shared/HoverActionMenu.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 8294d34f5..f6f8d828c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -90,6 +90,11 @@ "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", + "openInViewer": "Open in Viewer", + "confirmClose": "Confirm Close", + "confirmCloseMessage": "Are you sure you want to close this file?", + "confirmCloseCancel": "Cancel", + "confirmCloseConfirm": "Close File", "fileSelected": "Selected: {{filename}}", "chooseFile": "Choose File", "filesSelected": "{{count}} files selected", diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/components/fileEditor/FileEditor.module.css index 17184bbf4..4f26c8bce 100644 --- a/frontend/src/components/fileEditor/FileEditor.module.css +++ b/frontend/src/components/fileEditor/FileEditor.module.css @@ -9,7 +9,7 @@ transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; max-width: 100%; max-height: 100%; - overflow: hidden; + overflow: visible; margin-left: 0.5rem; margin-right: 0.5rem; } diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index bf3ddd885..5f14779a6 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -1,10 +1,11 @@ -import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core'; +import React, { useState, useCallback, useRef, useMemo } from 'react'; +import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import CloseIcon from '@mui/icons-material/Close'; +import VisibilityIcon from '@mui/icons-material/Visibility'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; @@ -19,6 +20,7 @@ import { useFileState } from '../../contexts/file/fileHooks'; import { FileId } from '../../types/file'; import { formatFileSize } from '../../utils/fileUtils'; import ToolChain from '../shared/ToolChain'; +import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu'; @@ -60,8 +62,9 @@ const FileEditorThumbnail = ({ // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); const dragElementRef = useRef(null); - const [actionsWidth, setActionsWidth] = useState(undefined); - const [showActions, setShowActions] = useState(false); + const [showHoverMenu, setShowHoverMenu] = useState(false); + const isMobile = useMediaQuery('(max-width: 1024px)'); + const [showCloseModal, setShowCloseModal] = useState(false); // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { @@ -155,46 +158,66 @@ const FileEditorThumbnail = ({ }; }, [file.id, file.name, selectedFiles, onReorderFiles]); - // Update dropdown width on resize - useEffect(() => { - const update = () => { - if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); - }; - update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); + // Handle close with confirmation + const handleCloseWithConfirmation = useCallback(() => { + setShowCloseModal(true); }, []); - // Close the actions dropdown when hovering outside this file card (and its dropdown) - useEffect(() => { - if (!showActions) return; + const handleConfirmClose = useCallback(() => { + onCloseFile(file.id); + alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 }); + setShowCloseModal(false); + }, [file.id, file.name, onCloseFile]); - const isInsideCard = (target: EventTarget | null) => { - const container = dragElementRef.current; - if (!container) return false; - return target instanceof Node && container.contains(target); - }; + const handleCancelClose = useCallback(() => { + setShowCloseModal(false); + }, []); - const handleMouseMove = (e: MouseEvent) => { - if (!isInsideCard(e.target)) { - setShowActions(false); - } - }; - - const handleTouchStart = (e: TouchEvent) => { - // On touch devices, close if the touch target is outside the card - if (!isInsideCard(e.target)) { - setShowActions(false); - } - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('touchstart', handleTouchStart, { passive: true }); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('touchstart', handleTouchStart); - }; - }, [showActions]); + // Build hover menu actions + const hoverActions = useMemo(() => [ + { + id: 'view', + icon: , + label: t('openInViewer', 'Open in Viewer'), + onClick: (e) => { + e.stopPropagation(); + onViewFile(file.id); + }, + }, + { + id: 'download', + icon: , + label: t('download', 'Download'), + onClick: (e) => { + e.stopPropagation(); + onDownloadFile(file.id); + alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); + }, + }, + { + id: 'unzip', + icon: , + label: t('fileManager.unzip', 'Unzip'), + onClick: (e) => { + e.stopPropagation(); + if (onUnzipFile) { + onUnzipFile(file.id); + alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); + } + }, + hidden: !isZipFile || !onUnzipFile, + }, + { + id: 'close', + icon: , + label: t('close', 'Close'), + onClick: (e) => { + e.stopPropagation(); + handleCloseWithConfirmation(); + }, + color: 'red', + } + ], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]); // ---- Card interactions ---- const handleCardClick = () => { @@ -232,6 +255,8 @@ const FileEditorThumbnail = ({ role="listitem" aria-selected={isSelected} onClick={handleCardClick} + onMouseEnter={() => setShowHoverMenu(true)} + onMouseLeave={() => setShowHoverMenu(false)} onDoubleClick={handleCardDoubleClick} > {/* Header bar */} @@ -289,98 +314,9 @@ const FileEditorThumbnail = ({ {isPinned ? : } - - {/* Download icon */} - - { - e.stopPropagation(); - onDownloadFile(file.id); - alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); - }} - > - - - - - {/* Kebab menu */} - { - e.stopPropagation(); - setShowActions((v) => !v); - }} - > - - - {/* Actions overlay */} - {showActions && ( -
e.stopPropagation()} - > - - - - - {isZipFile && onUnzipFile && ( - - )} - -
- - -
- )} - {/* Title + meta line */}
)}
+ + {/* Hover Menu */} + + + {/* Close Confirmation Modal */} + + + {t('confirmCloseMessage', 'Are you sure you want to close this file?')} + + {file.name} + + + + + + +
); }; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 59e5819d9..f6d77a3e4 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useState, useEffect, useRef } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core'; +import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; +import { Text, Checkbox } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import RotateLeftIcon from '@mui/icons-material/RotateLeft'; @@ -12,6 +13,7 @@ import { PDFPage, PDFDocument } from '../../types/pageEditor'; import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; import styles from './PageEditor.module.css'; +import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu'; interface PageThumbnailProps { @@ -65,6 +67,8 @@ const PageThumbnail: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); + const [isHovered, setIsHovered] = useState(false); + const isMobile = useMediaQuery('(max-width: 1024px)'); const dragElementRef = useRef(null); const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); @@ -270,8 +274,75 @@ const PageThumbnail: React.FC = ({ const handleMouseLeave = useCallback(() => { setIsMouseDown(false); setMouseStartPos(null); + setIsHovered(false); }, []); + // Build hover menu actions + const hoverActions = useMemo(() => [ + { + id: 'move-left', + icon: , + label: 'Move Left', + onClick: (e) => { + e.stopPropagation(); + if (index > 0 && !movingPage && !isAnimating) { + onSetMovingPage(page.pageNumber); + onReorderPages(page.pageNumber, index - 1); + setTimeout(() => onSetMovingPage(null), 650); + onSetStatus(`Moved page ${page.pageNumber} left`); + } + }, + disabled: index === 0 + }, + { + id: 'move-right', + icon: , + label: 'Move Right', + onClick: (e) => { + e.stopPropagation(); + if (index < totalPages - 1 && !movingPage && !isAnimating) { + onSetMovingPage(page.pageNumber); + onReorderPages(page.pageNumber, index + 1); + setTimeout(() => onSetMovingPage(null), 650); + onSetStatus(`Moved page ${page.pageNumber} right`); + } + }, + disabled: index === totalPages - 1 + }, + { + id: 'rotate-left', + icon: , + label: 'Rotate Left', + onClick: handleRotateLeft, + }, + { + id: 'rotate-right', + icon: , + label: 'Rotate Right', + onClick: handleRotateRight, + }, + { + id: 'delete', + icon: , + label: 'Delete Page', + onClick: handleDelete, + color: 'red', + }, + { + id: 'split', + icon: , + label: 'Split After', + onClick: handleSplit, + hidden: index >= totalPages - 1, + }, + { + id: 'insert', + icon: , + label: 'Insert File After', + onClick: handleInsertFileAfter, + } + ], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]); + return (
= ({ draggable={false} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} + onMouseEnter={() => setIsHovered(true)} onMouseLeave={handleMouseLeave} > { @@ -413,128 +485,12 @@ const PageThumbnail: React.FC = ({ {page.pageNumber} -
e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - - { - e.stopPropagation(); - if (index > 0 && !movingPage && !isAnimating) { - onSetMovingPage(page.pageNumber); - // Actually move the page left (swap with previous page) - onReorderPages(page.pageNumber, index - 1); - setTimeout(() => onSetMovingPage(null), 650); - onSetStatus(`Moved page ${page.pageNumber} left`); - } - }} - > - - - - - - { - e.stopPropagation(); - if (index < totalPages - 1 && !movingPage && !isAnimating) { - onSetMovingPage(page.pageNumber); - // Actually move the page right (swap with next page) - onReorderPages(page.pageNumber, index + 1); - setTimeout(() => onSetMovingPage(null), 650); - onSetStatus(`Moved page ${page.pageNumber} right`); - } - }} - > - - - - - - - - - - - - - - - - - - - - - - - {index < totalPages - 1 && ( - - - - - - )} - - - - - - -
+ />
diff --git a/frontend/src/components/shared/HoverActionMenu.module.css b/frontend/src/components/shared/HoverActionMenu.module.css new file mode 100644 index 000000000..27974f152 --- /dev/null +++ b/frontend/src/components/shared/HoverActionMenu.module.css @@ -0,0 +1,28 @@ +/* Base Hover Menu */ +.hoverMenu { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + align-items: center; + background: var(--bg-toolbar); + border: 1px solid var(--border-default); + padding: 6px 12px; + border-radius: 20px; + box-shadow: var(--shadow-md); + z-index: 30; + white-space: nowrap; + pointer-events: auto; + transition: opacity 0.2s ease-in-out; +} + +/* Inside positioning (Page Editor style) - within container */ +.inside { + bottom: 8px; +} + +/* Outside positioning (File Editor style) - below container */ +.outside { + bottom: -8px; +} diff --git a/frontend/src/components/shared/HoverActionMenu.tsx b/frontend/src/components/shared/HoverActionMenu.tsx new file mode 100644 index 000000000..1c55f5f0a --- /dev/null +++ b/frontend/src/components/shared/HoverActionMenu.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { ActionIcon, Tooltip } from '@mantine/core'; +import styles from './HoverActionMenu.module.css'; + +export interface HoverAction { + id: string; + icon: React.ReactNode; + label: string; + onClick: (e: React.MouseEvent) => void; + disabled?: boolean; + color?: string; + hidden?: boolean; +} + +interface HoverActionMenuProps { + show: boolean; + actions: HoverAction[]; + position?: 'inside' | 'outside'; + className?: string; +} + +const HoverActionMenu: React.FC = ({ + show, + actions, + position = 'inside', + className = '' +}) => { + const visibleActions = actions.filter(action => !action.hidden); + + if (visibleActions.length === 0) { + return null; + } + + return ( +
e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {visibleActions.map((action) => ( + + + {action.icon} + + + ))} +
+ ); +}; + +export default HoverActionMenu;