From 9861332040612ed154eedce120c488769fb29cc0 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:09:41 +0100 Subject: [PATCH] Feature/v2/tooltips (#4112) # Description of Changes - added tooltips to ocr and compress - added the tooltip component which can be used either directly, or through the toolstep component --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/public/logo-tooltip.svg | 4 + .../src/components/shared/QuickAccessBar.tsx | 36 +-- frontend/src/components/shared/Tooltip.tsx | 243 ++++++++++++++++++ .../shared/tooltip/Tooltip.README.md | 223 ++++++++++++++++ .../shared/tooltip/Tooltip.module.css | 191 ++++++++++++++ .../shared/tooltip/TooltipContent.tsx | 78 ++++++ .../src/components/tools/shared/ToolStep.tsx | 49 +++- .../src/components/tooltips/CompressTips.ts | 30 +++ frontend/src/components/tooltips/OCRTips.ts | 36 +++ frontend/src/contexts/SidebarContext.tsx | 47 ++++ frontend/src/hooks/useTooltipPosition.ts | 177 +++++++++++++ frontend/src/pages/HomePage.tsx | 27 +- frontend/src/styles/theme.css | 15 ++ frontend/src/tools/Compress.tsx | 3 + frontend/src/tools/OCR.tsx | 3 + frontend/src/types/sidebar.ts | 46 ++++ frontend/src/types/tips.ts | 13 + frontend/src/utils/genericUtils.ts | 42 +++ frontend/src/utils/sidebarUtils.ts | 34 +++ 19 files changed, 1256 insertions(+), 41 deletions(-) create mode 100644 frontend/public/logo-tooltip.svg create mode 100644 frontend/src/components/shared/Tooltip.tsx create mode 100644 frontend/src/components/shared/tooltip/Tooltip.README.md create mode 100644 frontend/src/components/shared/tooltip/Tooltip.module.css create mode 100644 frontend/src/components/shared/tooltip/TooltipContent.tsx create mode 100644 frontend/src/components/tooltips/CompressTips.ts create mode 100644 frontend/src/components/tooltips/OCRTips.ts create mode 100644 frontend/src/contexts/SidebarContext.tsx create mode 100644 frontend/src/hooks/useTooltipPosition.ts create mode 100644 frontend/src/types/sidebar.ts create mode 100644 frontend/src/types/tips.ts create mode 100644 frontend/src/utils/genericUtils.ts create mode 100644 frontend/src/utils/sidebarUtils.ts diff --git a/frontend/public/logo-tooltip.svg b/frontend/public/logo-tooltip.svg new file mode 100644 index 000000000..2d53f287c --- /dev/null +++ b/frontend/public/logo-tooltip.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 2f78a0a9f..fb27b1c2c 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, forwardRef } from "react"; import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core"; import MenuBookIcon from "@mui/icons-material/MenuBookRounded"; import AppsIcon from "@mui/icons-material/AppsRounded"; @@ -8,32 +8,12 @@ import FolderIcon from "@mui/icons-material/FolderRounded"; import PersonIcon from "@mui/icons-material/PersonRounded"; import NotificationsIcon from "@mui/icons-material/NotificationsRounded"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; -import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; +import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar'; import './QuickAccessBar.css'; -interface QuickAccessBarProps { - onToolsClick: () => void; - onReaderToggle: () => void; - selectedToolKey?: string; - toolRegistry: any; - leftPanelView: 'toolPicker' | 'toolContent'; - readerMode: boolean; -} - -interface ButtonConfig { - id: string; - name: string; - icon: React.ReactNode; - tooltip: string; - isRound?: boolean; - size?: 'sm' | 'md' | 'lg' | 'xl'; - onClick: () => void; - type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions -} - function NavHeader({ activeButton, setActiveButton, @@ -104,14 +84,10 @@ function NavHeader({ ); } -const QuickAccessBar = ({ +const QuickAccessBar = forwardRef(({ onToolsClick, onReaderToggle, - selectedToolKey, - toolRegistry, - leftPanelView, - readerMode, -}: QuickAccessBarProps) => { +}, ref) => { const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const [configModalOpen, setConfigModalOpen] = useState(false); @@ -234,6 +210,8 @@ const QuickAccessBar = ({ return (
{/* Fixed header outside scrollable area */} @@ -335,6 +313,6 @@ const QuickAccessBar = ({ />
); -}; +}); export default QuickAccessBar; \ No newline at end of file diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx new file mode 100644 index 000000000..6deda77c4 --- /dev/null +++ b/frontend/src/components/shared/Tooltip.tsx @@ -0,0 +1,243 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils'; +import { useTooltipPosition } from '../../hooks/useTooltipPosition'; +import { TooltipContent, TooltipTip } from './tooltip/TooltipContent'; +import { useSidebarContext } from '../../contexts/SidebarContext'; +import styles from './tooltip/Tooltip.module.css' + +export interface TooltipProps { + sidebarTooltip?: boolean; + position?: 'right' | 'left' | 'top' | 'bottom'; + content?: React.ReactNode; + tips?: TooltipTip[]; + children: React.ReactElement; + offset?: number; + maxWidth?: number | string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + arrow?: boolean; + portalTarget?: HTMLElement; + header?: { + title: string; + logo?: React.ReactNode; + }; +} + +export const Tooltip: React.FC = ({ + sidebarTooltip = false, + position = 'right', + content, + tips, + children, + offset: gap = 8, + maxWidth = 280, + open: controlledOpen, + onOpenChange, + arrow = false, + portalTarget, + header, +}) => { + const [internalOpen, setInternalOpen] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + + // Get sidebar context for tooltip positioning + const sidebarContext = sidebarTooltip ? useSidebarContext() : null; + + // Always use controlled mode - if no controlled props provided, use internal state + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const handleOpenChange = (newOpen: boolean) => { + if (isControlled) { + onOpenChange?.(newOpen); + } else { + setInternalOpen(newOpen); + } + + // Reset pin state when closing + if (!newOpen) { + setIsPinned(false); + } + }; + + const handleTooltipClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsPinned(true); + }; + + const handleDocumentClick = (e: MouseEvent) => { + // If tooltip is pinned and we click outside of it, unpin it + if (isPinned && isClickOutside(e, tooltipRef.current)) { + setIsPinned(false); + handleOpenChange(false); + } + }; + + // Use the positioning hook + const { coords, positionReady } = useTooltipPosition({ + open, + sidebarTooltip, + position, + gap, + triggerRef, + tooltipRef, + sidebarRefs: sidebarContext?.sidebarRefs, + sidebarState: sidebarContext?.sidebarState + }); + + // Add document click listener for unpinning + useEffect(() => { + if (isPinned) { + return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener); + } + }, [isPinned]); + + const getArrowClass = () => { + // No arrow for sidebar tooltips + if (sidebarTooltip) return null; + + switch (position) { + case 'top': return "tooltip-arrow tooltip-arrow-top"; + case 'bottom': return "tooltip-arrow tooltip-arrow-bottom"; + case 'left': return "tooltip-arrow tooltip-arrow-left"; + case 'right': return "tooltip-arrow tooltip-arrow-right"; + default: return "tooltip-arrow tooltip-arrow-right"; + } + }; + + const getArrowStyleClass = (arrowClass: string) => { + const styleKey = arrowClass.split(' ')[1]; + // Handle both kebab-case and camelCase CSS module exports + return styles[styleKey as keyof typeof styles] || + styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] || + ''; + }; + + // Only show tooltip when position is ready and correct + const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true); + + const tooltipElement = shouldShowTooltip ? ( +
+ {isPinned && ( + + )} + {arrow && getArrowClass() && ( +
+ )} + {header && ( +
+
+ {header.logo || Stirling PDF} +
+ {header.title} +
+ )} + +
+ ) : null; + + const handleMouseEnter = (e: React.MouseEvent) => { + // Clear any existing timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + + // Only show on hover if not pinned + if (!isPinned) { + handleOpenChange(true); + } + + (children.props as any)?.onMouseEnter?.(e); + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + // Only hide on mouse leave if not pinned + if (!isPinned) { + // Add a small delay to prevent flickering + hoverTimeoutRef.current = setTimeout(() => { + handleOpenChange(false); + }, 100); + } + + (children.props as any)?.onMouseLeave?.(e); + }; + + const handleClick = (e: React.MouseEvent) => { + // Toggle pin state on click + if (open) { + setIsPinned(!isPinned); + } else { + handleOpenChange(true); + setIsPinned(true); + } + + (children.props as any)?.onClick?.(e); + }; + + // Take the child element and add tooltip behavior to it + const childWithTooltipHandlers = React.cloneElement(children as any, { + // Keep track of the element for positioning + ref: (node: HTMLElement) => { + triggerRef.current = node; + // Don't break if the child already has a ref + const originalRef = (children as any).ref; + if (typeof originalRef === 'function') { + originalRef(node); + } else if (originalRef && typeof originalRef === 'object') { + originalRef.current = node; + } + }, + // Add mouse events to show/hide tooltip + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onClick: handleClick, + }); + + return ( + <> + {childWithTooltipHandlers} + {portalTarget && document.body.contains(portalTarget) + ? tooltipElement && createPortal(tooltipElement, portalTarget) + : tooltipElement} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/shared/tooltip/Tooltip.README.md b/frontend/src/components/shared/tooltip/Tooltip.README.md new file mode 100644 index 000000000..df1a977b0 --- /dev/null +++ b/frontend/src/components/shared/tooltip/Tooltip.README.md @@ -0,0 +1,223 @@ +# Tooltip Component + +A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click. + +## Features + +- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds +- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements +- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality +- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX +- 🌙 **Theme Support**: Built-in dark mode and theme variable support +- ⚡ **Performance**: Memoized calculations and efficient event handling +- 📜 **Scrollable**: Content area scrolls when content exceeds max height +- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin +- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content +- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior + +## Behavior + +### Default Behavior (Controlled) +- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering +- **Click**: Click the trigger to pin the tooltip open +- **Click tooltip**: Pins the tooltip to keep it open +- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned) +- **Click outside**: Unpins and closes the tooltip +- **Visual indicator**: Pinned tooltips have a blue border and close button + +### Manual Control (Optional) +- Use `open` and `onOpenChange` props for complete external control +- Useful for complex state management or custom interaction patterns + +## Basic Usage + +```tsx +import { Tooltip } from '@/components/shared'; + +function MyComponent() { + return ( + + + + ); +} +``` + +## API Reference + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip | +| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body | +| `children` | `ReactElement` | **required** | Element that triggers the tooltip | +| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic | +| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) | +| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip | +| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip | +| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) | +| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control | +| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element | +| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into | +| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo | + +### TooltipTip Interface + +```typescript +interface TooltipTip { + title?: string; // Optional pill label + description?: string; // Optional description text (supports HTML including tags) + bullets?: string[]; // Optional bullet points (supports HTML including tags) + body?: React.ReactNode; // Optional custom JSX for this tip +} +``` + +## Usage Examples + +### Default Behavior (Recommended) + +```tsx +// Simple tooltip with hover and click-to-pin + + + + +// Structured content with tips +Auto skips pages that already contain text.", + "Force re-processes every page.", + "Strict stops if text is found.", + "Learn more" + ] + } + ]} + header={{ + title: "Basic Settings Overview", + logo: Logo + }} +> + + +``` + +### Custom JSX Content + +```tsx + +

Custom Content

+

Any JSX you want here

+ + External link +
+ } +> + + +``` + +### Mixed Content (Tips + Custom JSX) + +```tsx +Additional custom content below tips} +> + + +``` + +### Sidebar Tooltips + +```tsx +// For items in a sidebar/navigation + +
+ 📁 File Manager +
+
+``` + +### With Arrows + +```tsx + + + +``` + +### Manual Control (Advanced) + +```tsx +function ManualControlTooltip() { + const [open, setOpen] = useState(false); + + return ( + + + + ); +} +``` + +## Click-to-Pin Interaction + +### How to Use (Default Behavior) +1. **Hover** over the trigger element to show the tooltip +2. **Click** the trigger element to pin the tooltip open +3. **Click** the red X button in the top-right corner to close +4. **Click** anywhere outside the tooltip to close +5. **Click** the trigger again to toggle pin state + +### Visual States +- **Unpinned**: Normal tooltip appearance +- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner + +## Link Support + +The tooltip fully supports clickable links in all content areas: + +- **Descriptions**: Use `` in description strings +- **Bullets**: Use `` in bullet point strings +- **Body**: Use JSX `` elements in the body ReactNode +- **Content**: Use JSX `` elements in custom content + +Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`. + +## Positioning Logic + +### Regular Tooltips +- Uses the `position` prop to determine initial placement +- Automatically clamps to viewport boundaries +- Calculates optimal position based on trigger element's `getBoundingClientRect()` +- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped + +### Sidebar Tooltips +- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar +- Vertical positioning follows the trigger but clamps to viewport +- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position +- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed +- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`) +- **No arrows** - sidebar tooltips don't show arrows diff --git a/frontend/src/components/shared/tooltip/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css new file mode 100644 index 000000000..209f0dcbd --- /dev/null +++ b/frontend/src/components/shared/tooltip/Tooltip.module.css @@ -0,0 +1,191 @@ +/* Tooltip Container */ +.tooltip-container { + position: fixed; + border: 0.0625rem solid var(--border-default); + border-radius: 0.75rem; + background-color: var(--bg-raised); + box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05); + font-size: 0.875rem; + line-height: 1.5; + pointer-events: auto; + z-index: 9999; + transition: opacity 100ms ease-out, transform 100ms ease-out; + min-width: 25rem; + max-width: 50vh; + max-height: 80vh; + color: var(--text-primary); + display: flex; + flex-direction: column; +} + +/* Pinned tooltip indicator */ +.tooltip-container.pinned { + border-color: var(--primary-color, #3b82f6); + box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), 0 0 0 0.125rem rgba(59, 130, 246, 0.1); +} + +/* Pinned tooltip header */ +.tooltip-container.pinned .tooltip-header { + background-color: var(--primary-color, #3b82f6); + color: white; + border-color: var(--primary-color, #3b82f6); +} + +/* Close button */ +.tooltip-pin-button { + position: absolute; + top: -0.5rem; + right: 0.5rem; + font-size: 0.875rem; + background: var(--bg-raised); + padding: 0.25rem; + border-radius: 0.25rem; + border: 0.0625rem solid var(--primary-color, #3b82f6); + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + min-height: 1.5rem; +} + +.tooltip-pin-button .material-symbols-outlined { + font-size: 1rem; + line-height: 1; +} + +.tooltip-pin-button:hover { + background-color: #ef4444 !important; + border-color: #ef4444 !important; +} + +/* Tooltip Header */ +.tooltip-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background-color: var(--tooltip-header-bg); + color: var(--tooltip-header-color); + font-size: 0.875rem; + font-weight: 500; + border-top-left-radius: 0.75rem; + border-top-right-radius: 0.75rem; + margin: -0.0625rem -0.0625rem 0 -0.0625rem; + border: 0.0625rem solid var(--tooltip-border); + flex-shrink: 0; +} + +.tooltip-logo { + width: 1rem; + height: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.tooltip-title { + flex: 1; +} + +/* Tooltip Body */ +.tooltip-body { + padding: 1rem !important; + color: var(--text-primary) !important; + font-size: 0.875rem !important; + line-height: 1.6 !important; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.tooltip-body * { + color: var(--text-primary) !important; +} + +/* Link styling within tooltips */ +.tooltip-body a { + color: var(--link-color, #3b82f6) !important; + text-decoration: underline; + text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); + transition: color 0.2s ease, text-decoration-color 0.2s ease; +} + +.tooltip-body a:hover { + color: var(--link-hover-color, #2563eb) !important; + text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5)); +} + +.tooltip-container .tooltip-body { + color: var(--text-primary) !important; +} + +.tooltip-container .tooltip-body * { + color: var(--text-primary) !important; +} + +/* Ensure links maintain their styling */ +.tooltip-container .tooltip-body a { + color: var(--link-color, #3b82f6) !important; + text-decoration: underline; + text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); +} + +.tooltip-container .tooltip-body a:hover { + color: var(--link-hover-color, #2563eb) !important; + text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5)); +} + + +/* Tooltip Arrows */ +.tooltip-arrow { + position: absolute; + width: 0.5rem; + height: 0.5rem; + background: var(--bg-raised); + border: 0.0625rem solid var(--border-default); + transform: rotate(45deg); +} + + +.tooltip-arrow-sidebar { + top: 50%; + left: -0.25rem; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-top { + top: -0.25rem; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-top: none; + border-left: none; +} + +.tooltip-arrow-bottom { + bottom: -0.25rem; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-bottom: none; + border-right: none; +} + +.tooltip-arrow-left { + right: -0.25rem; + top: 50%; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-right { + left: -0.25rem; + top: 50%; + transform: translateY(-50%) rotate(45deg); + border-right: none; + border-top: none; +} \ No newline at end of file diff --git a/frontend/src/components/shared/tooltip/TooltipContent.tsx b/frontend/src/components/shared/tooltip/TooltipContent.tsx new file mode 100644 index 000000000..e3515e0e6 --- /dev/null +++ b/frontend/src/components/shared/tooltip/TooltipContent.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import styles from './Tooltip.module.css'; + +export interface TooltipTip { + title?: string; + description?: string; + bullets?: string[]; + body?: React.ReactNode; +} + +interface TooltipContentProps { + content?: React.ReactNode; + tips?: TooltipTip[]; +} + +export const TooltipContent: React.FC = ({ + content, + tips, +}) => { + return ( +
+
+ {tips ? ( + <> + {tips.map((tip, index) => ( +
+ {tip.title && ( +
+ {tip.title} +
+ )} + {tip.description && ( +

+ )} + {tip.bullets && tip.bullets.length > 0 && ( +

    + {tip.bullets.map((bullet, bulletIndex) => ( +
  • + ))} +
+ )} + {tip.body && ( +
+ {tip.body} +
+ )} +
+ ))} + {content && ( +
+ {content} +
+ )} + + ) : ( + content + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index c4f144bfc..5ae6aec9b 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -2,6 +2,8 @@ import React, { createContext, useContext, useMemo, useRef } from 'react'; import { Paper, Text, Stack, Box, Flex } from '@mantine/core'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { Tooltip } from '../../shared/Tooltip'; +import { TooltipTip } from '../../shared/tooltip/TooltipContent'; interface ToolStepContextType { visibleStepCount: number; @@ -20,8 +22,48 @@ export interface ToolStepProps { completedMessage?: string; helpText?: string; showNumber?: boolean; + tooltip?: { + content?: React.ReactNode; + tips?: TooltipTip[]; + header?: { + title: string; + logo?: React.ReactNode; + }; + }; } +const renderTooltipTitle = ( + title: string, + tooltip: ToolStepProps['tooltip'], + isCollapsed: boolean +) => { + if (tooltip && !isCollapsed) { + return ( + + e.stopPropagation()}> + + {title} + + + gpp_maybe + + + + ); + } + + return ( + + {title} + + ); +}; + const ToolStep = ({ title, isVisible = true, @@ -31,7 +73,8 @@ const ToolStep = ({ children, completedMessage, helpText, - showNumber + showNumber, + tooltip }: ToolStepProps) => { if (!isVisible) return null; @@ -70,9 +113,7 @@ const ToolStep = ({ {stepNumber} )} - - {title} - + {renderTooltipTitle(title, tooltip, isCollapsed)} {isCollapsed ? ( diff --git a/frontend/src/components/tooltips/CompressTips.ts b/frontend/src/components/tooltips/CompressTips.ts new file mode 100644 index 000000000..2fb2a0777 --- /dev/null +++ b/frontend/src/components/tooltips/CompressTips.ts @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const CompressTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("compress.tooltip.header.title", "Compress Settings Overview") + }, + tips: [ + { + title: t("compress.tooltip.description.title", "Description"), + description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.") + }, + { + title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"), + description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."), + bullets: [ + t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"), + t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size") + ] + }, + { + title: t("compress.tooltip.grayscale.title", "Grayscale"), + description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.") + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/components/tooltips/OCRTips.ts b/frontend/src/components/tooltips/OCRTips.ts new file mode 100644 index 000000000..1002182f2 --- /dev/null +++ b/frontend/src/components/tooltips/OCRTips.ts @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const OcrTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("ocr.tooltip.header.title", "OCR Settings Overview"), + }, + tips: [ + { + title: t("ocr.tooltip.mode.title", "OCR Mode"), + description: t("ocr.tooltip.mode.text", "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight."), + bullets: [ + t("ocr.tooltip.mode.bullet1", "Auto skips pages that already contain text layers."), + t("ocr.tooltip.mode.bullet2", "Force re-OCRs every page and replaces all the text."), + t("ocr.tooltip.mode.bullet3", "Strict halts if any selectable text is found.") + ] + }, + { + title: t("ocr.tooltip.languages.title", "Languages"), + description: t("ocr.tooltip.languages.text", "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection.") + }, + { + title: t("ocr.tooltip.output.title", "Output"), + description: t("ocr.tooltip.output.text", "Decide how you want the text output formatted:"), + bullets: [ + t("ocr.tooltip.output.bullet1", "Searchable PDF embeds text behind the original image."), + t("ocr.tooltip.output.bullet2", "HOCR XML returns a structured machine-readable file."), + t("ocr.tooltip.output.bullet3", "Plain-text sidecar creates a separate .txt file with raw content.") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/contexts/SidebarContext.tsx b/frontend/src/contexts/SidebarContext.tsx new file mode 100644 index 000000000..f09815c5c --- /dev/null +++ b/frontend/src/contexts/SidebarContext.tsx @@ -0,0 +1,47 @@ +import React, { createContext, useContext, useState, useRef } from 'react'; +import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar'; + +const SidebarContext = createContext(undefined); + +export function SidebarProvider({ children }: SidebarProviderProps) { + // All sidebar state management + const quickAccessRef = useRef(null); + const toolPanelRef = useRef(null); + + const [sidebarsVisible, setSidebarsVisible] = useState(true); + const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); + const [readerMode, setReaderMode] = useState(false); + + const sidebarState: SidebarState = { + sidebarsVisible, + leftPanelView, + readerMode, + }; + + const sidebarRefs: SidebarRefs = { + quickAccessRef, + toolPanelRef, + }; + + const contextValue: SidebarContextValue = { + sidebarState, + sidebarRefs, + setSidebarsVisible, + setLeftPanelView, + setReaderMode, + }; + + return ( + + {children} + + ); +} + +export function useSidebarContext(): SidebarContextValue { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error('useSidebarContext must be used within a SidebarProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/hooks/useTooltipPosition.ts b/frontend/src/hooks/useTooltipPosition.ts new file mode 100644 index 000000000..3651c1d47 --- /dev/null +++ b/frontend/src/hooks/useTooltipPosition.ts @@ -0,0 +1,177 @@ +import { useState, useEffect, useMemo } from 'react'; +import { clamp } from '../utils/genericUtils'; +import { getSidebarInfo } from '../utils/sidebarUtils'; +import { SidebarRefs, SidebarState } from '../types/sidebar'; + +type Position = 'right' | 'left' | 'top' | 'bottom'; + +interface PlacementResult { + top: number; + left: number; +} + +interface PositionState { + coords: { top: number; left: number; arrowOffset: number | null }; + positionReady: boolean; +} + +function place( + triggerRect: DOMRect, + tooltipRect: DOMRect, + position: Position, + offset: number +): PlacementResult { + let top = 0; + let left = 0; + + switch (position) { + case 'right': + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; + left = triggerRect.right + offset; + break; + case 'left': + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; + left = triggerRect.left - tooltipRect.width - offset; + break; + case 'top': + top = triggerRect.top - tooltipRect.height - offset; + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + break; + case 'bottom': + top = triggerRect.bottom + offset; + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + break; + } + + return { top, left }; +} + +export function useTooltipPosition({ + open, + sidebarTooltip, + position, + gap, + triggerRef, + tooltipRef, + sidebarRefs, + sidebarState +}: { + open: boolean; + sidebarTooltip: boolean; + position: Position; + gap: number; + triggerRef: React.RefObject; + tooltipRef: React.RefObject; + sidebarRefs?: SidebarRefs; + sidebarState?: SidebarState; +}): PositionState { + const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ + top: 0, + left: 0, + arrowOffset: null + }); + const [positionReady, setPositionReady] = useState(false); + + // Fallback sidebar position (only used as last resort) + const sidebarLeft = 240; + + const updatePosition = () => { + if (!triggerRef.current || !open) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + + let top: number; + let left: number; + let arrowOffset: number | null = null; + + if (sidebarTooltip) { + // Require sidebar refs and state for proper positioning + if (!sidebarRefs || !sidebarState) { + console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props'); + setPositionReady(false); + return; + } + + const sidebarInfo = getSidebarInfo(sidebarRefs, sidebarState); + const currentSidebarRight = sidebarInfo.rect ? sidebarInfo.rect.right : sidebarLeft; + + // Only show tooltip if we have the tool panel active + if (!sidebarInfo.isToolPanelActive) { + console.log('🚫 Not showing tooltip - tool panel not active'); + setPositionReady(false); + return; + } + + // Position to the right of active sidebar with 20px gap + left = currentSidebarRight + 20; + top = triggerRect.top; // Align top of tooltip with trigger element + + // Only clamp if we have tooltip dimensions + if (tooltipRef.current) { + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const maxTop = window.innerHeight - tooltipRect.height - 4; + const originalTop = top; + top = clamp(top, 4, maxTop); + + // If tooltip was clamped, adjust arrow position to stay aligned with trigger + if (originalTop !== top) { + arrowOffset = triggerRect.top + triggerRect.height / 2 - top; + } + } + + setCoords({ top, left, arrowOffset }); + setPositionReady(true); + } else { + // Regular tooltip logic + if (!tooltipRef.current) return; + + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const placement = place(triggerRect, tooltipRect, position, gap); + top = placement.top; + left = placement.left; + + // Clamp to viewport + top = clamp(top, 4, window.innerHeight - tooltipRect.height - 4); + left = clamp(left, 4, window.innerWidth - tooltipRect.width - 4); + + // Calculate arrow position to stay aligned with trigger + if (position === 'top' || position === 'bottom') { + // For top/bottom arrows, adjust horizontal position + const triggerCenter = triggerRect.left + triggerRect.width / 2; + const tooltipCenter = left + tooltipRect.width / 2; + if (Math.abs(triggerCenter - tooltipCenter) > 4) { + // Arrow needs adjustment + arrowOffset = triggerCenter - left - 4; // 4px is half arrow width + } + } else { + // For left/right arrows, adjust vertical position + const triggerCenter = triggerRect.top + triggerRect.height / 2; + const tooltipCenter = top + tooltipRect.height / 2; + if (Math.abs(triggerCenter - tooltipCenter) > 4) { + // Arrow needs adjustment + arrowOffset = triggerCenter - top - 4; // 4px is half arrow height + } + } + + setCoords({ top, left, arrowOffset }); + setPositionReady(true); + } + }; + + useEffect(() => { + if (!open) return; + + requestAnimationFrame(updatePosition); + + const handleUpdate = () => requestAnimationFrame(updatePosition); + window.addEventListener('scroll', handleUpdate, true); + window.addEventListener('resize', handleUpdate); + + return () => { + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + }; + }, [open, sidebarLeft, position, gap, sidebarTooltip]); + + return { coords, positionReady }; +} \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index cccce7667..94a81ee6d 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,11 +2,13 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from 'react-i18next'; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; +import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; import { useToolManagement } from "../hooks/useToolManagement"; import { useFileHandler } from "../hooks/useFileHandler"; import { Group, Box, Button } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import { PageEditorFunctions } from "../types/pageEditor"; +import { SidebarRefs, SidebarState } from "../types/sidebar"; import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; @@ -20,9 +22,20 @@ import QuickAccessBar from "../components/shared/QuickAccessBar"; import LandingPage from "../components/shared/LandingPage"; import FileUploadModal from "../components/shared/FileUploadModal"; + function HomePageContent() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); + const { + sidebarState, + sidebarRefs, + setSidebarsVisible, + setLeftPanelView, + setReaderMode + } = useSidebarContext(); + + const { sidebarsVisible, leftPanelView, readerMode } = sidebarState; + const { quickAccessRef, toolPanelRef } = sidebarRefs; const fileContext = useFileContext(); const { activeFiles, currentView, setCurrentView } = fileContext; @@ -37,9 +50,6 @@ function HomePageContent() { clearToolSelection, } = useToolManagement(); - const [sidebarsVisible, setSidebarsVisible] = useState(true); - const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); - const [readerMode, setReaderMode] = useState(false); const [pageEditorFunctions, setPageEditorFunctions] = useState(null); const [previewFile, setPreviewFile] = useState(null); @@ -92,16 +102,15 @@ function HomePageContent() { > {/* Quick Access Bar */} {/* Left: Tool Picker or Selected Tool Panel */}
- + + + ); } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 71443411f..1cf3581c4 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -103,6 +103,13 @@ --icon-config-bg: #9CA3AF; --icon-config-color: #FFFFFF; + /* Colors for tooltips */ + --tooltip-title-bg: #DBEFFF; + --tooltip-title-color: #31528E; + --tooltip-header-bg: #31528E; + --tooltip-header-color: white; + --tooltip-border: var(--border-default); + /* Inactive icon colors for light mode */ --icon-inactive-bg: #9CA3AF; --icon-inactive-color: #FFFFFF; @@ -201,6 +208,13 @@ --icon-inactive-bg: #2A2F36; --icon-inactive-color: #6E7581; + /* Dark mode tooltip colors */ + --tooltip-title-bg: #4B525A; + --tooltip-title-color: #fff; + --tooltip-header-bg: var(--bg-raised); + --tooltip-header-color: var(--text-primary); + --tooltip-border: var(--border-default); + --accent-interactive: #ffffff; --text-instruction: #ffffff; --text-brand: var(--color-gray-800); @@ -224,6 +238,7 @@ --drop-shadow-color: rgba(255, 255, 255, 0.08); --drop-shadow-color-strong: rgba(255, 255, 255, 0.04); --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04)); + /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index cc0cd5cbc..f4b50b264 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -17,6 +17,7 @@ import CompressSettings from "../components/tools/compress/CompressSettings"; import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; import { BaseToolProps } from "../types/tool"; +import { CompressTips } from "../components/tooltips/CompressTips"; const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); @@ -25,6 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const compressParams = useCompressParameters(); const compressOperation = useCompressOperation(); + const compressTips = CompressTips(); // Endpoint validation const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf"); @@ -104,6 +106,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isCompleted={settingsCollapsed} onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined} completedMessage={settingsCollapsed ? "Compression completed" : undefined} + tooltip={compressTips} > { const { t } = useTranslation(); @@ -26,6 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); + const ocrTips = OcrTips(); // Step expansion state management const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files'); @@ -126,6 +128,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { setExpandedStep(expandedStep === 'settings' ? null : 'settings'); }} completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined} + tooltip={ocrTips} > ; + toolPanelRef: React.RefObject; +} + +export interface SidebarInfo { + rect: DOMRect | null; + isToolPanelActive: boolean; + sidebarState: SidebarState; +} + +// Context-related interfaces +export interface SidebarContextValue { + sidebarState: SidebarState; + sidebarRefs: SidebarRefs; + setSidebarsVisible: React.Dispatch>; + setLeftPanelView: React.Dispatch>; + setReaderMode: React.Dispatch>; +} + +export interface SidebarProviderProps { + children: React.ReactNode; +} + +// QuickAccessBar related interfaces +export interface QuickAccessBarProps { + onToolsClick: () => void; + onReaderToggle: () => void; +} + +export interface ButtonConfig { + id: string; + name: string; + icon: React.ReactNode; + tooltip: string; + isRound?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + onClick: () => void; + type?: 'navigation' | 'modal' | 'action'; +} diff --git a/frontend/src/types/tips.ts b/frontend/src/types/tips.ts new file mode 100644 index 000000000..58519e114 --- /dev/null +++ b/frontend/src/types/tips.ts @@ -0,0 +1,13 @@ +export interface TooltipContent { + header?: { + title: string; + logo?: string | React.ReactNode; + }; + tips?: Array<{ + title?: string; + description?: string; + bullets?: string[]; + body?: React.ReactNode; + }>; + content?: React.ReactNode; +} \ No newline at end of file diff --git a/frontend/src/utils/genericUtils.ts b/frontend/src/utils/genericUtils.ts new file mode 100644 index 000000000..253346292 --- /dev/null +++ b/frontend/src/utils/genericUtils.ts @@ -0,0 +1,42 @@ +/** + * DOM utility functions for common operations + */ + +/** + * Clamps a value between a minimum and maximum + * @param value - The value to clamp + * @param min - The minimum allowed value + * @param max - The maximum allowed value + * @returns The clamped value + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Safely adds an event listener with proper cleanup + * @param target - The target element or window/document + * @param event - The event type + * @param handler - The event handler function + * @param options - Event listener options + * @returns A cleanup function to remove the listener + */ +export function addEventListenerWithCleanup( + target: EventTarget, + event: string, + handler: EventListener, + options?: boolean | AddEventListenerOptions +): () => void { + target.addEventListener(event, handler, options); + return () => target.removeEventListener(event, handler, options); +} + +/** + * Checks if a click event occurred outside of a specified element + * @param event - The click event + * @param element - The element to check against + * @returns True if the click was outside the element + */ +export function isClickOutside(event: MouseEvent, element: HTMLElement | null): boolean { + return element ? !element.contains(event.target as Node) : true; +} diff --git a/frontend/src/utils/sidebarUtils.ts b/frontend/src/utils/sidebarUtils.ts new file mode 100644 index 000000000..cef144971 --- /dev/null +++ b/frontend/src/utils/sidebarUtils.ts @@ -0,0 +1,34 @@ +import { SidebarRefs, SidebarState, SidebarInfo } from '../types/sidebar'; + +/** + * Gets the All tools sidebar information using React refs and state + * @param refs - Object containing refs to sidebar elements + * @param state - Current sidebar state + * @returns Object containing the sidebar rect and whether the tool panel is active + */ +export function getSidebarInfo(refs: SidebarRefs, state: SidebarState): SidebarInfo { + const { quickAccessRef, toolPanelRef } = refs; + const { sidebarsVisible, readerMode } = state; + + // Determine if tool panel should be active based on state + const isToolPanelActive = sidebarsVisible && !readerMode; + + let rect: DOMRect | null = null; + + if (isToolPanelActive && toolPanelRef.current) { + // Tool panel is expanded: use its rect + rect = toolPanelRef.current.getBoundingClientRect(); + } else if (quickAccessRef.current) { + // Fall back to quick access bar + // This probably isn't needed but if we ever have tooltips or modals that need to be positioned relative to the quick access bar, we can use this + rect = quickAccessRef.current.getBoundingClientRect(); + } + + return { + rect, + isToolPanelActive, + sidebarState: state + }; +} + + \ No newline at end of file