From 9aeb652cac3c7f4fa504960a29107fa8f540794d Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 7 Aug 2025 01:49:58 +0100 Subject: [PATCH] Changes: change px values to rem, chop up tooltip into managable pieces, extract some re-usable utils into a domUtils folder, rename some files and functions and more --- frontend/src/components/shared/Tooltip.tsx | 503 ------------------ .../shared/{ => tooltip}/Tooltip.README.md | 2 +- .../shared/{ => tooltip}/Tooltip.module.css | 68 +-- .../src/components/shared/tooltip/Tooltip.tsx | 237 +++++++++ .../shared/tooltip/TooltipContent.tsx | 78 +++ .../{COMPRESS_TIPS.ts => CompressTips.ts} | 6 +- .../tips/{OCR_TIPS.ts => OCRTips.ts} | 14 +- .../src/components/tools/shared/ToolStep.tsx | 56 +- frontend/src/hooks/useTooltipPosition.ts | 177 ++++++ frontend/src/tools/Compress.tsx | 4 +- frontend/src/tools/OCR.tsx | 4 +- frontend/src/utils/domUtils.ts | 102 ++++ 12 files changed, 677 insertions(+), 574 deletions(-) delete mode 100644 frontend/src/components/shared/Tooltip.tsx rename frontend/src/components/shared/{ => tooltip}/Tooltip.README.md (98%) rename frontend/src/components/shared/{ => tooltip}/Tooltip.module.css (75%) create mode 100644 frontend/src/components/shared/tooltip/Tooltip.tsx create mode 100644 frontend/src/components/shared/tooltip/TooltipContent.tsx rename frontend/src/components/tips/{COMPRESS_TIPS.ts => CompressTips.ts} (69%) rename frontend/src/components/tips/{OCR_TIPS.ts => OCRTips.ts} (56%) create mode 100644 frontend/src/hooks/useTooltipPosition.ts create mode 100644 frontend/src/utils/domUtils.ts diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx deleted file mode 100644 index c3b6146af..000000000 --- a/frontend/src/components/shared/Tooltip.tsx +++ /dev/null @@ -1,503 +0,0 @@ -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/shared/Tooltip.README.md b/frontend/src/components/shared/tooltip/Tooltip.README.md similarity index 98% rename from frontend/src/components/shared/Tooltip.README.md rename to frontend/src/components/shared/tooltip/Tooltip.README.md index 2ef438af3..50a4037c0 100644 --- a/frontend/src/components/shared/Tooltip.README.md +++ b/frontend/src/components/shared/tooltip/Tooltip.README.md @@ -6,7 +6,7 @@ A flexible, accessible tooltip component that supports both regular positioning - 🎯 **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 +- ♿ **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 diff --git a/frontend/src/components/shared/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css similarity index 75% rename from frontend/src/components/shared/Tooltip.module.css rename to frontend/src/components/shared/tooltip/Tooltip.module.css index 31cfd453b..209f0dcbd 100644 --- a/frontend/src/components/shared/Tooltip.module.css +++ b/frontend/src/components/shared/tooltip/Tooltip.module.css @@ -1,16 +1,16 @@ /* Tooltip Container */ .tooltip-container { position: fixed; - border: 1px solid var(--border-default); - border-radius: 12px; + border: 0.0625rem solid var(--border-default); + border-radius: 0.75rem; 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; + 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: 400px; + min-width: 25rem; max-width: 50vh; max-height: 80vh; color: var(--text-primary); @@ -21,7 +21,7 @@ /* 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); + 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 */ @@ -34,25 +34,25 @@ /* Close button */ .tooltip-pin-button { position: absolute; - top: -8px; - right: 8px; - font-size: 14px; + top: -0.5rem; + right: 0.5rem; + font-size: 0.875rem; background: var(--bg-raised); - padding: 4px; - border-radius: 4px; - border: 1px solid var(--primary-color, #3b82f6); + 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: 24px; - min-height: 24px; + min-width: 1.5rem; + min-height: 1.5rem; } .tooltip-pin-button .material-symbols-outlined { - font-size: 16px; + font-size: 1rem; line-height: 1; } @@ -65,22 +65,22 @@ .tooltip-header { display: flex; align-items: center; - gap: 8px; - padding: 12px 16px; + gap: 0.5rem; + padding: 0.75rem 1rem; background-color: var(--tooltip-header-bg); color: var(--tooltip-header-color); - font-size: 14px; + font-size: 0.875rem; font-weight: 500; - border-top-left-radius: 12px; - border-top-right-radius: 12px; - margin: -1px -1px 0 -1px; - border: 1px solid var(--tooltip-border); + 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: 16px; - height: 16px; + width: 1rem; + height: 1rem; display: flex; align-items: center; justify-content: center; @@ -92,9 +92,9 @@ /* Tooltip Body */ .tooltip-body { - padding: 16px !important; + padding: 1rem !important; color: var(--text-primary) !important; - font-size: 14px !important; + font-size: 0.875rem !important; line-height: 1.6 !important; overflow-y: auto; flex: 1; @@ -142,24 +142,24 @@ /* Tooltip Arrows */ .tooltip-arrow { position: absolute; - width: 8px; - height: 8px; + width: 0.5rem; + height: 0.5rem; background: var(--bg-raised); - border: 1px solid var(--border-default); + border: 0.0625rem solid var(--border-default); transform: rotate(45deg); } .tooltip-arrow-sidebar { top: 50%; - left: -4px; + left: -0.25rem; transform: translateY(-50%) rotate(45deg); border-left: none; border-bottom: none; } .tooltip-arrow-top { - top: -4px; + top: -0.25rem; left: 50%; transform: translateX(-50%) rotate(45deg); border-top: none; @@ -167,7 +167,7 @@ } .tooltip-arrow-bottom { - bottom: -4px; + bottom: -0.25rem; left: 50%; transform: translateX(-50%) rotate(45deg); border-bottom: none; @@ -175,7 +175,7 @@ } .tooltip-arrow-left { - right: -4px; + right: -0.25rem; top: 50%; transform: translateY(-50%) rotate(45deg); border-left: none; @@ -183,7 +183,7 @@ } .tooltip-arrow-right { - left: -4px; + left: -0.25rem; top: 50%; transform: translateY(-50%) rotate(45deg); border-right: none; diff --git a/frontend/src/components/shared/tooltip/Tooltip.tsx b/frontend/src/components/shared/tooltip/Tooltip.tsx new file mode 100644 index 000000000..7f82f07c4 --- /dev/null +++ b/frontend/src/components/shared/tooltip/Tooltip.tsx @@ -0,0 +1,237 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { isClickOutside, addEventListenerWithCleanup } from '../../../utils/domUtils'; +import { useTooltipPosition } from '../../../hooks/useTooltipPosition'; +import { TooltipContent, TooltipTip } from './TooltipContent'; +import styles from './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); + + // 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 + }); + + // 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/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/tips/COMPRESS_TIPS.ts b/frontend/src/components/tips/CompressTips.ts similarity index 69% rename from frontend/src/components/tips/COMPRESS_TIPS.ts rename to frontend/src/components/tips/CompressTips.ts index 2813ee75a..62d8a0972 100644 --- a/frontend/src/components/tips/COMPRESS_TIPS.ts +++ b/frontend/src/components/tips/CompressTips.ts @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { TooltipContent } from './types'; -export const useCompressTips = (): TooltipContent => { +export const CompressTips = (): TooltipContent => { const { t } = useTranslation(); return { @@ -11,11 +11,11 @@ export const useCompressTips = (): TooltipContent => { 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.") + 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."), + 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") diff --git a/frontend/src/components/tips/OCR_TIPS.ts b/frontend/src/components/tips/OCRTips.ts similarity index 56% rename from frontend/src/components/tips/OCR_TIPS.ts rename to frontend/src/components/tips/OCRTips.ts index 885224b85..1d5c7015a 100644 --- a/frontend/src/components/tips/OCR_TIPS.ts +++ b/frontend/src/components/tips/OCRTips.ts @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { TooltipContent } from './types'; -export const useOcrTips = (): TooltipContent => { +export const OcrTips = (): TooltipContent => { const { t } = useTranslation(); return { @@ -13,9 +13,9 @@ export const useOcrTips = (): TooltipContent => { 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.") + 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.") ] }, { @@ -26,9 +26,9 @@ export const useOcrTips = (): TooltipContent => { 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.") + 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.") ] } ] diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index aebfed98c..f1eb9a2b1 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -2,7 +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'; +import { Tooltip, TooltipTip } from '../../shared/tooltip/Tooltip'; interface ToolStepContextType { visibleStepCount: number; @@ -31,6 +31,38 @@ export interface ToolStepProps { }; } +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, @@ -80,27 +112,7 @@ const ToolStep = ({ {stepNumber} )} - {tooltip && !isCollapsed ? ( - - e.stopPropagation()}> - - {title} - - - gpp_maybe - - - - ) : ( - - {title} - - )} + {renderTooltipTitle(title, tooltip, isCollapsed)} {isCollapsed ? ( diff --git a/frontend/src/hooks/useTooltipPosition.ts b/frontend/src/hooks/useTooltipPosition.ts new file mode 100644 index 000000000..5f24861ff --- /dev/null +++ b/frontend/src/hooks/useTooltipPosition.ts @@ -0,0 +1,177 @@ +import { useState, useEffect, useMemo } from 'react'; +import { clamp, getSidebarRect } from '../utils/domUtils'; + +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 +}: { + open: boolean; + sidebarTooltip: boolean; + position: Position; + gap: number; + triggerRef: React.RefObject; + tooltipRef: React.RefObject; +}): PositionState { + const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ + top: 0, + left: 0, + arrowOffset: null + }); + const [positionReady, setPositionReady] = useState(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 () => { + 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/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index 2de51baf4..6db5ec89e 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -17,7 +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 { useCompressTips } from "../components/tips/COMPRESS_TIPS"; +import { CompressTips } from "../components/tips/CompressTips"; const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); @@ -26,7 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const compressParams = useCompressParameters(); const compressOperation = useCompressOperation(); - const compressTips = useCompressTips(); + const compressTips = CompressTips(); // Endpoint validation const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf"); diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index 8ac5c1453..9e026577c 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -18,7 +18,7 @@ import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings"; import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters"; import { useOCROperation } from "../hooks/tools/ocr/useOCROperation"; import { BaseToolProps } from "../types/tool"; -import { useOcrTips } from "../components/tips/OCR_TIPS"; +import { OcrTips } from "../components/tips/OCRTips"; const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); @@ -27,7 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); - const ocrTips = useOcrTips(); + const ocrTips = OcrTips(); // Step expansion state management const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files'); diff --git a/frontend/src/utils/domUtils.ts b/frontend/src/utils/domUtils.ts new file mode 100644 index 000000000..9b1c37f23 --- /dev/null +++ b/frontend/src/utils/domUtils.ts @@ -0,0 +1,102 @@ +/** + * 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; +} + +/** + * Gets the sidebar rectangle for tooltip positioning + * @returns Object containing the sidebar rect and whether it's the correct sidebar + */ +export 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 }; +} \ No newline at end of file