Stirling-PDF/frontend/src/hooks/useTooltipPosition.ts
EthanHealy01 9861332040
Feature/v2/tooltips (#4112)
# Description of Changes

- added tooltips to ocr and compress
- added the tooltip component which can be used either directly, or
through the toolstep component

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-08 12:09:41 +01:00

177 lines
5.5 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { clamp } from '../utils/genericUtils';
import { getSidebarInfo } from '../utils/sidebarUtils';
import { SidebarRefs, SidebarState } from '../types/sidebar';
type Position = 'right' | 'left' | 'top' | 'bottom';
interface PlacementResult {
top: number;
left: number;
}
interface PositionState {
coords: { top: number; left: number; arrowOffset: number | null };
positionReady: boolean;
}
function place(
triggerRect: DOMRect,
tooltipRect: DOMRect,
position: Position,
offset: number
): PlacementResult {
let top = 0;
let left = 0;
switch (position) {
case 'right':
top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
left = triggerRect.right + offset;
break;
case 'left':
top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
left = triggerRect.left - tooltipRect.width - offset;
break;
case 'top':
top = triggerRect.top - tooltipRect.height - offset;
left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
break;
case 'bottom':
top = triggerRect.bottom + offset;
left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
break;
}
return { top, left };
}
export function useTooltipPosition({
open,
sidebarTooltip,
position,
gap,
triggerRef,
tooltipRef,
sidebarRefs,
sidebarState
}: {
open: boolean;
sidebarTooltip: boolean;
position: Position;
gap: number;
triggerRef: React.RefObject<HTMLElement | null>;
tooltipRef: React.RefObject<HTMLDivElement | null>;
sidebarRefs?: SidebarRefs;
sidebarState?: SidebarState;
}): PositionState {
const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({
top: 0,
left: 0,
arrowOffset: null
});
const [positionReady, setPositionReady] = useState(false);
// Fallback sidebar position (only used as last resort)
const sidebarLeft = 240;
const updatePosition = () => {
if (!triggerRef.current || !open) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
let top: number;
let left: number;
let arrowOffset: number | null = null;
if (sidebarTooltip) {
// Require sidebar refs and state for proper positioning
if (!sidebarRefs || !sidebarState) {
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
setPositionReady(false);
return;
}
const sidebarInfo = getSidebarInfo(sidebarRefs, sidebarState);
const currentSidebarRight = sidebarInfo.rect ? sidebarInfo.rect.right : sidebarLeft;
// Only show tooltip if we have the tool panel active
if (!sidebarInfo.isToolPanelActive) {
console.log('🚫 Not showing tooltip - tool panel not active');
setPositionReady(false);
return;
}
// Position to the right of active sidebar with 20px gap
left = currentSidebarRight + 20;
top = triggerRect.top; // Align top of tooltip with trigger element
// Only clamp if we have tooltip dimensions
if (tooltipRef.current) {
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const maxTop = window.innerHeight - tooltipRect.height - 4;
const originalTop = top;
top = clamp(top, 4, maxTop);
// If tooltip was clamped, adjust arrow position to stay aligned with trigger
if (originalTop !== top) {
arrowOffset = triggerRect.top + triggerRect.height / 2 - top;
}
}
setCoords({ top, left, arrowOffset });
setPositionReady(true);
} else {
// Regular tooltip logic
if (!tooltipRef.current) return;
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const placement = place(triggerRect, tooltipRect, position, gap);
top = placement.top;
left = placement.left;
// Clamp to viewport
top = clamp(top, 4, window.innerHeight - tooltipRect.height - 4);
left = clamp(left, 4, window.innerWidth - tooltipRect.width - 4);
// Calculate arrow position to stay aligned with trigger
if (position === 'top' || position === 'bottom') {
// For top/bottom arrows, adjust horizontal position
const triggerCenter = triggerRect.left + triggerRect.width / 2;
const tooltipCenter = left + tooltipRect.width / 2;
if (Math.abs(triggerCenter - tooltipCenter) > 4) {
// Arrow needs adjustment
arrowOffset = triggerCenter - left - 4; // 4px is half arrow width
}
} else {
// For left/right arrows, adjust vertical position
const triggerCenter = triggerRect.top + triggerRect.height / 2;
const tooltipCenter = top + tooltipRect.height / 2;
if (Math.abs(triggerCenter - tooltipCenter) > 4) {
// Arrow needs adjustment
arrowOffset = triggerCenter - top - 4; // 4px is half arrow height
}
}
setCoords({ top, left, arrowOffset });
setPositionReady(true);
}
};
useEffect(() => {
if (!open) return;
requestAnimationFrame(updatePosition);
const handleUpdate = () => requestAnimationFrame(updatePosition);
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
};
}, [open, sidebarLeft, position, gap, sidebarTooltip]);
return { coords, positionReady };
}