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 22a49617e..4497a8975 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -213,6 +213,7 @@ const QuickAccessBar = ({ return (
{/* Fixed header outside scrollable area */} diff --git a/frontend/src/components/shared/Tooltip.README.md b/frontend/src/components/shared/Tooltip.README.md new file mode 100644 index 000000000..2ef438af3 --- /dev/null +++ b/frontend/src/components/shared/Tooltip.README.md @@ -0,0 +1,221 @@ +# 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 both mouse and keyboard interactions +- 🎨 **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 +- Automatically detects sidebar width or falls back to 240px +- **No arrows** - sidebar tooltips don't show arrows diff --git a/frontend/src/components/shared/Tooltip.module.css b/frontend/src/components/shared/Tooltip.module.css new file mode 100644 index 000000000..18b825c77 --- /dev/null +++ b/frontend/src/components/shared/Tooltip.module.css @@ -0,0 +1,184 @@ +/* Tooltip Container */ +.tooltip-container { + position: fixed; + border: 1px solid var(--border-default); + border-radius: 12px; + background-color: var(--bg-raised); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + font-size: 14px; + line-height: 1.5; + pointer-events: auto; + z-index: 9999; + transition: opacity 100ms ease-out, transform 100ms ease-out; + min-width: 400px; + 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 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05), 0 0 0 2px rgba(59, 130, 246, 0.1); +} + +/* Close button */ +.tooltip-pin-button { + position: absolute; + top: -8px; + right: 8px; + font-size: 14px; + background: var(--bg-raised); + padding: 4px; + border-radius: 4px; + border: 1px 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: 24px; + min-height: 24px; +} + +.tooltip-pin-button .material-symbols-outlined { + font-size: 16px; + 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: 8px; + padding: 12px 16px; + background-color: var(--tooltip-header-bg); + color: var(--tooltip-header-color); + font-size: 14px; + font-weight: 500; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + margin: -1px -1px 0 -1px; + border: 1px solid var(--tooltip-border); + flex-shrink: 0; +} + +.tooltip-logo { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.tooltip-title { + flex: 1; +} + +/* Tooltip Body */ +.tooltip-body { + padding: 16px !important; + color: var(--text-primary) !important; + font-size: 14px !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: 8px; + height: 8px; + background: var(--bg-raised); + border: 1px solid var(--border-default); + transform: rotate(45deg); +} + + +.tooltip-arrow-sidebar { + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-top { + top: -4px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-top: none; + border-left: none; +} + +.tooltip-arrow-bottom { + bottom: -4px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-bottom: none; + border-right: none; +} + +.tooltip-arrow-left { + right: -4px; + top: 50%; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-right { + left: -4px; + 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.tsx b/frontend/src/components/shared/Tooltip.tsx new file mode 100644 index 000000000..c3b6146af --- /dev/null +++ b/frontend/src/components/shared/Tooltip.tsx @@ -0,0 +1,503 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './Tooltip.module.css'; + +export interface TooltipTip { + title?: string; + description?: string; + bullets?: string[]; + body?: React.ReactNode; +} + +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; + }; +} + +type Position = 'right' | 'left' | 'top' | 'bottom'; + +interface PlacementResult { + top: number; + left: number; +} + +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 }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function getSidebarRect(): { rect: DOMRect | null, isCorrectSidebar: boolean } { + // Find the rightmost sidebar - this will be the "All Tools" expanded panel + const allSidebars = []; + + // Find the QuickAccessBar (narrow left bar) + const quickAccessBar = document.querySelector('[data-sidebar="quick-access"]'); + if (quickAccessBar) { + const rect = quickAccessBar.getBoundingClientRect(); + if (rect.width > 0) { + allSidebars.push({ + element: 'QuickAccessBar', + selector: '[data-sidebar="quick-access"]', + rect + }); + } + } + + // Find the tool panel (the expanded "All Tools" panel) + const toolPanel = document.querySelector('[data-sidebar="tool-panel"]'); + if (toolPanel) { + const rect = toolPanel.getBoundingClientRect(); + if (rect.width > 0) { + allSidebars.push({ + element: 'ToolPanel', + selector: '[data-sidebar="tool-panel"]', + rect + }); + } + } + + // Use the rightmost sidebar (which should be the tool panel when expanded) + if (allSidebars.length > 0) { + const rightmostSidebar = allSidebars.reduce((rightmost, current) => { + return current.rect.right > rightmost.rect.right ? current : rightmost; + }); + + // Only consider it correct if we're using the ToolPanel (expanded All Tools sidebar) + const isCorrectSidebar = rightmostSidebar.element === 'ToolPanel'; + + console.log('✅ Tooltip positioning using:', { + element: rightmostSidebar.element, + selector: rightmostSidebar.selector, + width: rightmostSidebar.rect.width, + right: rightmostSidebar.rect.right, + isCorrectSidebar, + rect: rightmostSidebar.rect + }); + + return { rect: rightmostSidebar.rect, isCorrectSidebar }; + } + + console.warn('⚠️ No sidebars found, using fallback positioning'); + // Final fallback + return { rect: new DOMRect(0, 0, 280, window.innerHeight), isCorrectSidebar: false }; +} + +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 [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ top: 0, left: 0, arrowOffset: null }); + const [positionReady, setPositionReady] = useState(false); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const hoverTimeoutRef = useRef | null>(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 position ready state when closing + if (!newOpen) { + setPositionReady(false); + 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 && tooltipRef.current && !tooltipRef.current.contains(e.target as Node)) { + setIsPinned(false); + handleOpenChange(false); + } + }; + + // Memoize sidebar position for performance + const sidebarLeft = useMemo(() => { + if (!sidebarTooltip) return 0; + const sidebarInfo = getSidebarRect(); + return sidebarInfo.rect ? sidebarInfo.rect.right : 240; + }, [sidebarTooltip]); + + 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) { + // Get fresh sidebar position each time + const sidebarInfo = getSidebarRect(); + const currentSidebarRight = sidebarInfo.rect ? sidebarInfo.rect.right : sidebarLeft; + + // Only show tooltip if we have the correct sidebar (ToolPanel) + if (!sidebarInfo.isCorrectSidebar) { + console.log('🚫 Not showing tooltip - wrong sidebar detected'); + setPositionReady(false); + return; + } + + // Position to the right of correct sidebar with 20px gap + left = currentSidebarRight + 20; + top = triggerRect.top; // Align top of tooltip with trigger element + + console.log('Sidebar tooltip positioning:', { + currentSidebarRight, + triggerRect, + calculatedLeft: left, + calculatedTop: top, + isCorrectSidebar: sidebarInfo.isCorrectSidebar + }); + + // 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 () => { + clearTimeout(hoverTimeoutRef.current!); + + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + }; + }, [open, sidebarLeft, position, gap, sidebarTooltip]); + // Add document click listener for unpinning + useEffect(() => { + if (isPinned) { + document.addEventListener('click', handleDocumentClick); + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + } + }, [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} +
+ )} +
+
+ {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 + )} +
+
+
+ ) : 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); + }; + + const enhancedChildren = React.cloneElement(children as any, { + ref: (node: HTMLElement) => { + triggerRef.current = node; + // Forward ref if children already has one + const originalRef = (children as any).ref; + if (typeof originalRef === 'function') { + originalRef(node); + } else if (originalRef && typeof originalRef === 'object') { + originalRef.current = node; + } + }, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onClick: handleClick, + onFocus: (e: React.FocusEvent) => { + if (!isPinned) { + handleOpenChange(true); + } + (children.props as any)?.onFocus?.(e); + }, + onBlur: (e: React.FocusEvent) => { + if (!isPinned) { + handleOpenChange(false); + } + (children.props as any)?.onBlur?.(e); + }, + }); + + return ( + <> + {enhancedChildren} + {portalTarget && document.body.contains(portalTarget) + ? tooltipElement && createPortal(tooltipElement, portalTarget) + : tooltipElement} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/tips/COMPRESS_TIPS.ts b/frontend/src/components/tips/COMPRESS_TIPS.ts new file mode 100644 index 000000000..ac6b16d37 --- /dev/null +++ b/frontend/src/components/tips/COMPRESS_TIPS.ts @@ -0,0 +1,25 @@ +import { TooltipContent } from './types'; + +export const compressTips: TooltipContent = { + header: { + title: "Settings Overview" + }, + tips: [ + { + title: "Compression Method", + description: "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: "Quality Adjustment", + description: "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: [ + "Lower values preserve quality", + "Higher values reduce file size" + ] + }, + { + title: "Grayscale", + description: "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/tips/OCR_TIPS.ts b/frontend/src/components/tips/OCR_TIPS.ts new file mode 100644 index 000000000..3b9666d14 --- /dev/null +++ b/frontend/src/components/tips/OCR_TIPS.ts @@ -0,0 +1,31 @@ +import { TooltipContent } from './types'; + +export const ocrTips: TooltipContent = { + header: { + title: "Basic Settings Overview", + }, + tips: [ + { + title: "OCR Mode", + description: "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight.", + bullets: [ + "Auto skips pages that already contain text layers.", + "Force re-OCRs every page and replaces all the text.", + "Strict halts if any selectable text is found." + ] + }, + { + title: "Languages", + description: "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection." + }, + { + title: "Output", + description: "Decide how you want the text output formatted:", + bullets: [ + "Searchable PDF embeds text behind the original image.", + "HOCR XML returns a structured machine-readable file.", + "Plain-text sidecar creates a separate .txt file with raw content." + ] + } + ] +}; \ No newline at end of file diff --git a/frontend/src/components/tips/types.ts b/frontend/src/components/tips/types.ts new file mode 100644 index 000000000..58519e114 --- /dev/null +++ b/frontend/src/components/tips/types.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/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 1d64a25a3..6a7481464 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -2,6 +2,7 @@ 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, TooltipTip } from '../../shared/Tooltip'; interface ToolStepContextType { visibleStepCount: number; @@ -20,6 +21,14 @@ export interface ToolStepProps { completedMessage?: string; helpText?: string; showNumber?: boolean; + tooltip?: { + content?: React.ReactNode; + tips?: TooltipTip[]; + header?: { + title: string; + logo?: React.ReactNode; + }; + }; } const ToolStep = ({ @@ -31,7 +40,8 @@ const ToolStep = ({ children, completedMessage, helpText, - showNumber + showNumber, + tooltip }: ToolStepProps) => { if (!isVisible) return null; @@ -70,9 +80,27 @@ const ToolStep = ({ {stepNumber} )} - - {title} - + {tooltip && !isCollapsed ? ( + + e.stopPropagation()}> + + {title} + + + gpp_maybe + + + + ) : ( + + {title} + + )} {isCollapsed ? ( diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d24c58b44..9d069578d 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -104,6 +104,7 @@ function HomePageContent() { {/* Left: Tool Picker or Selected Tool Panel */}
{ const { t } = useTranslation(); @@ -104,6 +105,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isCompleted={settingsCollapsed} onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined} completedMessage={settingsCollapsed ? "Compression completed" : undefined} + tooltip={compressTips} > { const { t } = useTranslation(); @@ -126,6 +127,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { setExpandedStep(expandedStep === 'settings' ? null : 'settings'); }} completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined} + tooltip={ocrTips} >