mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-11 13:48:37 +02:00
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
This commit is contained in:
parent
e8a95aab0b
commit
9aeb652cac
@ -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<TooltipProps> = ({
|
||||
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<HTMLElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 ? (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
maxWidth,
|
||||
zIndex: 9999,
|
||||
visibility: 'visible',
|
||||
opacity: 1,
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={handleTooltipClick}
|
||||
>
|
||||
{isPinned && (
|
||||
<button
|
||||
className={styles['tooltip-pin-button']}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsPinned(false);
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
title="Close tooltip"
|
||||
>
|
||||
<span className="material-symbols-rounded">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{arrow && getArrowClass() && (
|
||||
<div
|
||||
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
|
||||
style={coords.arrowOffset !== null ? {
|
||||
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
{header && (
|
||||
<div className={styles['tooltip-header']}>
|
||||
<div className={styles['tooltip-logo']}>
|
||||
{header.logo || <img src="/logo-tooltip.svg" alt="Stirling PDF" style={{ width: '1.4rem', height: '1.4rem', display: 'block' }} />}
|
||||
</div>
|
||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={styles['tooltip-body']}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
padding: '16px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--text-primary)' }}>
|
||||
{tips ? (
|
||||
<>
|
||||
{tips.map((tip, index) => (
|
||||
<div key={index} style={{ marginBottom: index < tips.length - 1 ? '24px' : '0' }}>
|
||||
{tip.title && (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: 'var(--tooltip-title-bg)',
|
||||
color: 'var(--tooltip-title-color)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{tip.title}
|
||||
</div>
|
||||
)}
|
||||
{tip.description && (
|
||||
<p style={{ margin: '0 0 12px 0', color: 'var(--text-secondary)', fontSize: '13px' }} dangerouslySetInnerHTML={{ __html: tip.description }} />
|
||||
)}
|
||||
{tip.bullets && tip.bullets.length > 0 && (
|
||||
<ul style={{ margin: '0', paddingLeft: '16px', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
{tip.bullets.map((bullet, bulletIndex) => (
|
||||
<li key={bulletIndex} style={{ marginBottom: '6px' }} dangerouslySetInnerHTML={{ __html: bullet }} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tip.body && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{tip.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{content && (
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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
|
@ -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;
|
237
frontend/src/components/shared/tooltip/Tooltip.tsx
Normal file
237
frontend/src/components/shared/tooltip/Tooltip.tsx
Normal file
@ -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<TooltipProps> = ({
|
||||
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<HTMLElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 ? (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
maxWidth,
|
||||
zIndex: 9999,
|
||||
visibility: 'visible',
|
||||
opacity: 1,
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={handleTooltipClick}
|
||||
>
|
||||
{isPinned && (
|
||||
<button
|
||||
className={styles['tooltip-pin-button']}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsPinned(false);
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
title="Close tooltip"
|
||||
>
|
||||
<span className="material-symbols-rounded">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{arrow && getArrowClass() && (
|
||||
<div
|
||||
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
|
||||
style={coords.arrowOffset !== null ? {
|
||||
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
{header && (
|
||||
<div className={styles['tooltip-header']}>
|
||||
<div className={styles['tooltip-logo']}>
|
||||
{header.logo || <img src="/logo-tooltip.svg" alt="Stirling PDF" style={{ width: '1.4rem', height: '1.4rem', display: 'block' }} />}
|
||||
</div>
|
||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<TooltipContent
|
||||
content={content}
|
||||
tips={tips}
|
||||
/>
|
||||
</div>
|
||||
) : 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}
|
||||
</>
|
||||
);
|
||||
};
|
78
frontend/src/components/shared/tooltip/TooltipContent.tsx
Normal file
78
frontend/src/components/shared/tooltip/TooltipContent.tsx
Normal file
@ -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<TooltipContentProps> = ({
|
||||
content,
|
||||
tips,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles['tooltip-body']}`}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
padding: '16px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--text-primary)' }}>
|
||||
{tips ? (
|
||||
<>
|
||||
{tips.map((tip, index) => (
|
||||
<div key={index} style={{ marginBottom: index < tips.length - 1 ? '24px' : '0' }}>
|
||||
{tip.title && (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: 'var(--tooltip-title-bg)',
|
||||
color: 'var(--tooltip-title-color)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{tip.title}
|
||||
</div>
|
||||
)}
|
||||
{tip.description && (
|
||||
<p style={{ margin: '0 0 12px 0', color: 'var(--text-secondary)', fontSize: '13px' }} dangerouslySetInnerHTML={{ __html: tip.description }} />
|
||||
)}
|
||||
{tip.bullets && tip.bullets.length > 0 && (
|
||||
<ul style={{ margin: '0', paddingLeft: '16px', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
{tip.bullets.map((bullet, bulletIndex) => (
|
||||
<li key={bulletIndex} style={{ marginBottom: '6px' }} dangerouslySetInnerHTML={{ __html: bullet }} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tip.body && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{tip.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{content && (
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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 <strong>File Size</strong> to enter a target size and have us adjust quality for you. Pick <strong>Quality</strong> 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. <strong>Lower values (1-3)</strong> preserve quality but result in larger files. <strong>Higher values (7-9)</strong> 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")
|
@ -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", "<strong>Auto</strong> skips pages that already contain text layers."),
|
||||
t("ocr.tooltip.mode.bullet2", "<strong>Force</strong> re-OCRs every page and replaces all the text."),
|
||||
t("ocr.tooltip.mode.bullet3", "<strong>Strict</strong> 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", "<strong>Searchable PDF</strong> embeds text behind the original image."),
|
||||
t("ocr.tooltip.output.bullet2", "<strong>HOCR XML</strong> returns a structured machine-readable file."),
|
||||
t("ocr.tooltip.output.bullet3", "<strong>Plain-text sidecar</strong> 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.")
|
||||
]
|
||||
}
|
||||
]
|
@ -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 (
|
||||
<Tooltip
|
||||
content={tooltip.content}
|
||||
tips={tooltip.tips}
|
||||
header={tooltip.header}
|
||||
sidebarTooltip={true}
|
||||
>
|
||||
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
|
||||
gpp_maybe
|
||||
</span>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolStep = ({
|
||||
title,
|
||||
isVisible = true,
|
||||
@ -80,27 +112,7 @@ const ToolStep = ({
|
||||
{stepNumber}
|
||||
</Text>
|
||||
)}
|
||||
{tooltip && !isCollapsed ? (
|
||||
<Tooltip
|
||||
content={tooltip.content}
|
||||
tips={tooltip.tips}
|
||||
header={tooltip.header}
|
||||
sidebarTooltip={true}
|
||||
>
|
||||
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
|
||||
gpp_maybe
|
||||
</span>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{renderTooltipTitle(title, tooltip, isCollapsed)}
|
||||
</Flex>
|
||||
|
||||
{isCollapsed ? (
|
||||
|
177
frontend/src/hooks/useTooltipPosition.ts
Normal file
177
frontend/src/hooks/useTooltipPosition.ts
Normal file
@ -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<HTMLElement | null>;
|
||||
tooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
}): 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 };
|
||||
}
|
@ -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");
|
||||
|
@ -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');
|
||||
|
102
frontend/src/utils/domUtils.ts
Normal file
102
frontend/src/utils/domUtils.ts
Normal file
@ -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 };
|
||||
}
|
Loading…
Reference in New Issue
Block a user