mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-11 13:48:37 +02:00
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.
This commit is contained in:
parent
f4e4831c0d
commit
9861332040
4
frontend/public/logo-tooltip.svg
Normal file
4
frontend/public/logo-tooltip.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" fill="none">
|
||||||
|
<path d="M7.26375 95.8344L123.374 4.32822e-05L123.375 89.4987L7.26375 185.333L7.26375 95.8344Z" fill="white"/>
|
||||||
|
<path d="M68.4794 102.395L184.728 6.44717L184.728 96.052L68.4794 192L68.4794 102.395Z" fill="white" fill-opacity="0.6"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 339 B |
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useState, useRef, forwardRef } from "react";
|
||||||
import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core";
|
import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core";
|
||||||
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
|
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
|
||||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||||
@ -8,32 +8,12 @@ import FolderIcon from "@mui/icons-material/FolderRounded";
|
|||||||
import PersonIcon from "@mui/icons-material/PersonRounded";
|
import PersonIcon from "@mui/icons-material/PersonRounded";
|
||||||
import NotificationsIcon from "@mui/icons-material/NotificationsRounded";
|
import NotificationsIcon from "@mui/icons-material/NotificationsRounded";
|
||||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
|
||||||
import AppConfigModal from './AppConfigModal';
|
import AppConfigModal from './AppConfigModal';
|
||||||
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
||||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||||
|
import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar';
|
||||||
import './QuickAccessBar.css';
|
import './QuickAccessBar.css';
|
||||||
|
|
||||||
interface QuickAccessBarProps {
|
|
||||||
onToolsClick: () => void;
|
|
||||||
onReaderToggle: () => void;
|
|
||||||
selectedToolKey?: string;
|
|
||||||
toolRegistry: any;
|
|
||||||
leftPanelView: 'toolPicker' | 'toolContent';
|
|
||||||
readerMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ButtonConfig {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
tooltip: string;
|
|
||||||
isRound?: boolean;
|
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
||||||
onClick: () => void;
|
|
||||||
type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavHeader({
|
function NavHeader({
|
||||||
activeButton,
|
activeButton,
|
||||||
setActiveButton,
|
setActiveButton,
|
||||||
@ -104,14 +84,10 @@ function NavHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuickAccessBar = ({
|
const QuickAccessBar = forwardRef<HTMLDivElement, QuickAccessBarProps>(({
|
||||||
onToolsClick,
|
onToolsClick,
|
||||||
onReaderToggle,
|
onReaderToggle,
|
||||||
selectedToolKey,
|
}, ref) => {
|
||||||
toolRegistry,
|
|
||||||
leftPanelView,
|
|
||||||
readerMode,
|
|
||||||
}: QuickAccessBarProps) => {
|
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
@ -234,6 +210,8 @@ const QuickAccessBar = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="quick-access"
|
||||||
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||||
>
|
>
|
||||||
{/* Fixed header outside scrollable area */}
|
{/* Fixed header outside scrollable area */}
|
||||||
@ -335,6 +313,6 @@ const QuickAccessBar = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default QuickAccessBar;
|
export default QuickAccessBar;
|
243
frontend/src/components/shared/Tooltip.tsx
Normal file
243
frontend/src/components/shared/Tooltip.tsx
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
|
||||||
|
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
|
||||||
|
import { TooltipContent, TooltipTip } from './tooltip/TooltipContent';
|
||||||
|
import { useSidebarContext } from '../../contexts/SidebarContext';
|
||||||
|
import styles from './tooltip/Tooltip.module.css'
|
||||||
|
|
||||||
|
export interface TooltipProps {
|
||||||
|
sidebarTooltip?: boolean;
|
||||||
|
position?: 'right' | 'left' | 'top' | 'bottom';
|
||||||
|
content?: React.ReactNode;
|
||||||
|
tips?: TooltipTip[];
|
||||||
|
children: React.ReactElement;
|
||||||
|
offset?: number;
|
||||||
|
maxWidth?: number | string;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
arrow?: boolean;
|
||||||
|
portalTarget?: HTMLElement;
|
||||||
|
header?: {
|
||||||
|
title: string;
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip: React.FC<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);
|
||||||
|
|
||||||
|
// Get sidebar context for tooltip positioning
|
||||||
|
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
|
||||||
|
|
||||||
|
// Always use controlled mode - if no controlled props provided, use internal state
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onOpenChange?.(newOpen);
|
||||||
|
} else {
|
||||||
|
setInternalOpen(newOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset pin state when closing
|
||||||
|
if (!newOpen) {
|
||||||
|
setIsPinned(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTooltipClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsPinned(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentClick = (e: MouseEvent) => {
|
||||||
|
// If tooltip is pinned and we click outside of it, unpin it
|
||||||
|
if (isPinned && isClickOutside(e, tooltipRef.current)) {
|
||||||
|
setIsPinned(false);
|
||||||
|
handleOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the positioning hook
|
||||||
|
const { coords, positionReady } = useTooltipPosition({
|
||||||
|
open,
|
||||||
|
sidebarTooltip,
|
||||||
|
position,
|
||||||
|
gap,
|
||||||
|
triggerRef,
|
||||||
|
tooltipRef,
|
||||||
|
sidebarRefs: sidebarContext?.sidebarRefs,
|
||||||
|
sidebarState: sidebarContext?.sidebarState
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add document click listener for unpinning
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPinned) {
|
||||||
|
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
|
||||||
|
}
|
||||||
|
}, [isPinned]);
|
||||||
|
|
||||||
|
const getArrowClass = () => {
|
||||||
|
// No arrow for sidebar tooltips
|
||||||
|
if (sidebarTooltip) return null;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'top': return "tooltip-arrow tooltip-arrow-top";
|
||||||
|
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
|
||||||
|
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
||||||
|
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
||||||
|
default: return "tooltip-arrow tooltip-arrow-right";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArrowStyleClass = (arrowClass: string) => {
|
||||||
|
const styleKey = arrowClass.split(' ')[1];
|
||||||
|
// Handle both kebab-case and camelCase CSS module exports
|
||||||
|
return styles[styleKey as keyof typeof styles] ||
|
||||||
|
styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] ||
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only show tooltip when position is ready and correct
|
||||||
|
const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true);
|
||||||
|
|
||||||
|
const tooltipElement = shouldShowTooltip ? (
|
||||||
|
<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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
223
frontend/src/components/shared/tooltip/Tooltip.README.md
Normal file
223
frontend/src/components/shared/tooltip/Tooltip.README.md
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# Tooltip Component
|
||||||
|
|
||||||
|
A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds
|
||||||
|
- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements
|
||||||
|
- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality
|
||||||
|
- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX
|
||||||
|
- 🌙 **Theme Support**: Built-in dark mode and theme variable support
|
||||||
|
- ⚡ **Performance**: Memoized calculations and efficient event handling
|
||||||
|
- 📜 **Scrollable**: Content area scrolls when content exceeds max height
|
||||||
|
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
|
||||||
|
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
|
||||||
|
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
### Default Behavior (Controlled)
|
||||||
|
- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering
|
||||||
|
- **Click**: Click the trigger to pin the tooltip open
|
||||||
|
- **Click tooltip**: Pins the tooltip to keep it open
|
||||||
|
- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned)
|
||||||
|
- **Click outside**: Unpins and closes the tooltip
|
||||||
|
- **Visual indicator**: Pinned tooltips have a blue border and close button
|
||||||
|
|
||||||
|
### Manual Control (Optional)
|
||||||
|
- Use `open` and `onOpenChange` props for complete external control
|
||||||
|
- Useful for complex state management or custom interaction patterns
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Tooltip } from '@/components/shared';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
return (
|
||||||
|
<Tooltip content="This is a helpful tooltip">
|
||||||
|
<button>Hover me</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip |
|
||||||
|
| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body |
|
||||||
|
| `children` | `ReactElement` | **required** | Element that triggers the tooltip |
|
||||||
|
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic |
|
||||||
|
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) |
|
||||||
|
| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip |
|
||||||
|
| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip |
|
||||||
|
| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) |
|
||||||
|
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control |
|
||||||
|
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
|
||||||
|
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
|
||||||
|
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
|
||||||
|
|
||||||
|
### TooltipTip Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TooltipTip {
|
||||||
|
title?: string; // Optional pill label
|
||||||
|
description?: string; // Optional description text (supports HTML including <a> tags)
|
||||||
|
bullets?: string[]; // Optional bullet points (supports HTML including <a> tags)
|
||||||
|
body?: React.ReactNode; // Optional custom JSX for this tip
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Default Behavior (Recommended)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Simple tooltip with hover and click-to-pin
|
||||||
|
<Tooltip content="This tooltip appears on hover and pins on click">
|
||||||
|
<button>Hover me</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
// Structured content with tips
|
||||||
|
<Tooltip
|
||||||
|
tips={[
|
||||||
|
{
|
||||||
|
title: "OCR Mode",
|
||||||
|
description: "Choose how to process text in your documents.",
|
||||||
|
bullets: [
|
||||||
|
"<strong>Auto</strong> skips pages that already contain text.",
|
||||||
|
"<strong>Force</strong> re-processes every page.",
|
||||||
|
"<strong>Strict</strong> stops if text is found.",
|
||||||
|
"<a href='https://docs.example.com' target='_blank'>Learn more</a>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
header={{
|
||||||
|
title: "Basic Settings Overview",
|
||||||
|
logo: <img src="/logo.svg" alt="Logo" />
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button>Settings</button>
|
||||||
|
</Tooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom JSX Content
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<h3>Custom Content</h3>
|
||||||
|
<p>Any JSX you want here</p>
|
||||||
|
<button>Action</button>
|
||||||
|
<a href="https://example.com">External link</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button>Custom tooltip</button>
|
||||||
|
</Tooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mixed Content (Tips + Custom JSX)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tooltip
|
||||||
|
tips={[
|
||||||
|
{ title: "Section", description: "Description" }
|
||||||
|
]}
|
||||||
|
content={<div>Additional custom content below tips</div>}
|
||||||
|
>
|
||||||
|
<button>Mixed content</button>
|
||||||
|
</Tooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar Tooltips
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// For items in a sidebar/navigation
|
||||||
|
<Tooltip
|
||||||
|
content="This tooltip appears to the right of the sidebar"
|
||||||
|
sidebarTooltip={true}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item">
|
||||||
|
📁 File Manager
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Arrows
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tooltip
|
||||||
|
content="Tooltip with arrow pointing to trigger"
|
||||||
|
arrow={true}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<button>Arrow tooltip</button>
|
||||||
|
</Tooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Control (Advanced)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ManualControlTooltip() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content="Fully controlled tooltip"
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<button onClick={() => setOpen(!open)}>
|
||||||
|
Toggle tooltip
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Click-to-Pin Interaction
|
||||||
|
|
||||||
|
### How to Use (Default Behavior)
|
||||||
|
1. **Hover** over the trigger element to show the tooltip
|
||||||
|
2. **Click** the trigger element to pin the tooltip open
|
||||||
|
3. **Click** the red X button in the top-right corner to close
|
||||||
|
4. **Click** anywhere outside the tooltip to close
|
||||||
|
5. **Click** the trigger again to toggle pin state
|
||||||
|
|
||||||
|
### Visual States
|
||||||
|
- **Unpinned**: Normal tooltip appearance
|
||||||
|
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
|
||||||
|
|
||||||
|
## Link Support
|
||||||
|
|
||||||
|
The tooltip fully supports clickable links in all content areas:
|
||||||
|
|
||||||
|
- **Descriptions**: Use `<a href="...">` in description strings
|
||||||
|
- **Bullets**: Use `<a href="...">` in bullet point strings
|
||||||
|
- **Body**: Use JSX `<a>` elements in the body ReactNode
|
||||||
|
- **Content**: Use JSX `<a>` elements in custom content
|
||||||
|
|
||||||
|
Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`.
|
||||||
|
|
||||||
|
## Positioning Logic
|
||||||
|
|
||||||
|
### Regular Tooltips
|
||||||
|
- Uses the `position` prop to determine initial placement
|
||||||
|
- Automatically clamps to viewport boundaries
|
||||||
|
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
|
||||||
|
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
|
||||||
|
|
||||||
|
### Sidebar Tooltips
|
||||||
|
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
|
||||||
|
- Vertical positioning follows the trigger but clamps to viewport
|
||||||
|
- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position
|
||||||
|
- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed
|
||||||
|
- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`)
|
||||||
|
- **No arrows** - sidebar tooltips don't show arrows
|
191
frontend/src/components/shared/tooltip/Tooltip.module.css
Normal file
191
frontend/src/components/shared/tooltip/Tooltip.module.css
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
/* Tooltip Container */
|
||||||
|
.tooltip-container {
|
||||||
|
position: fixed;
|
||||||
|
border: 0.0625rem solid var(--border-default);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background-color: var(--bg-raised);
|
||||||
|
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: opacity 100ms ease-out, transform 100ms ease-out;
|
||||||
|
min-width: 25rem;
|
||||||
|
max-width: 50vh;
|
||||||
|
max-height: 80vh;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pinned tooltip indicator */
|
||||||
|
.tooltip-container.pinned {
|
||||||
|
border-color: var(--primary-color, #3b82f6);
|
||||||
|
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), 0 0 0 0.125rem rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pinned tooltip header */
|
||||||
|
.tooltip-container.pinned .tooltip-header {
|
||||||
|
background-color: var(--primary-color, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button */
|
||||||
|
.tooltip-pin-button {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 0.0625rem solid var(--primary-color, #3b82f6);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pin-button .material-symbols-outlined {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-pin-button:hover {
|
||||||
|
background-color: #ef4444 !important;
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Header */
|
||||||
|
.tooltip-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: var(--tooltip-header-bg);
|
||||||
|
color: var(--tooltip-header-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-top-left-radius: 0.75rem;
|
||||||
|
border-top-right-radius: 0.75rem;
|
||||||
|
margin: -0.0625rem -0.0625rem 0 -0.0625rem;
|
||||||
|
border: 0.0625rem solid var(--tooltip-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-logo {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Body */
|
||||||
|
.tooltip-body {
|
||||||
|
padding: 1rem !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-body * {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link styling within tooltips */
|
||||||
|
.tooltip-body a {
|
||||||
|
color: var(--link-color, #3b82f6) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
|
||||||
|
transition: color 0.2s ease, text-decoration-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-body a:hover {
|
||||||
|
color: var(--link-hover-color, #2563eb) !important;
|
||||||
|
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-container .tooltip-body {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-container .tooltip-body * {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure links maintain their styling */
|
||||||
|
.tooltip-container .tooltip-body a {
|
||||||
|
color: var(--link-color, #3b82f6) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-container .tooltip-body a:hover {
|
||||||
|
color: var(--link-hover-color, #2563eb) !important;
|
||||||
|
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip Arrows */
|
||||||
|
.tooltip-arrow {
|
||||||
|
position: absolute;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 0.0625rem solid var(--border-default);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tooltip-arrow-sidebar {
|
||||||
|
top: 50%;
|
||||||
|
left: -0.25rem;
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-arrow-top {
|
||||||
|
top: -0.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-arrow-bottom {
|
||||||
|
bottom: -0.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
border-bottom: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-arrow-left {
|
||||||
|
right: -0.25rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-arrow-right {
|
||||||
|
left: -0.25rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -2,6 +2,8 @@ import React, { createContext, useContext, useMemo, useRef } from 'react';
|
|||||||
import { Paper, Text, Stack, Box, Flex } from '@mantine/core';
|
import { Paper, Text, Stack, Box, Flex } from '@mantine/core';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { TooltipTip } from '../../shared/tooltip/TooltipContent';
|
||||||
|
|
||||||
interface ToolStepContextType {
|
interface ToolStepContextType {
|
||||||
visibleStepCount: number;
|
visibleStepCount: number;
|
||||||
@ -20,8 +22,48 @@ export interface ToolStepProps {
|
|||||||
completedMessage?: string;
|
completedMessage?: string;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
showNumber?: boolean;
|
showNumber?: boolean;
|
||||||
|
tooltip?: {
|
||||||
|
content?: React.ReactNode;
|
||||||
|
tips?: TooltipTip[];
|
||||||
|
header?: {
|
||||||
|
title: string;
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderTooltipTitle = (
|
||||||
|
title: string,
|
||||||
|
tooltip: ToolStepProps['tooltip'],
|
||||||
|
isCollapsed: boolean
|
||||||
|
) => {
|
||||||
|
if (tooltip && !isCollapsed) {
|
||||||
|
return (
|
||||||
|
<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 = ({
|
const ToolStep = ({
|
||||||
title,
|
title,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
@ -31,7 +73,8 @@ const ToolStep = ({
|
|||||||
children,
|
children,
|
||||||
completedMessage,
|
completedMessage,
|
||||||
helpText,
|
helpText,
|
||||||
showNumber
|
showNumber,
|
||||||
|
tooltip
|
||||||
}: ToolStepProps) => {
|
}: ToolStepProps) => {
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
@ -70,9 +113,7 @@ const ToolStep = ({
|
|||||||
{stepNumber}
|
{stepNumber}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text fw={500} size="lg">
|
{renderTooltipTitle(title, tooltip, isCollapsed)}
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
|
30
frontend/src/components/tooltips/CompressTips.ts
Normal file
30
frontend/src/components/tooltips/CompressTips.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TooltipContent } from '../../types/tips';
|
||||||
|
|
||||||
|
export const CompressTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("compress.tooltip.header.title", "Compress Settings Overview")
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
title: t("compress.tooltip.description.title", "Description"),
|
||||||
|
description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"),
|
||||||
|
description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."),
|
||||||
|
bullets: [
|
||||||
|
t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"),
|
||||||
|
t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("compress.tooltip.grayscale.title", "Grayscale"),
|
||||||
|
description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
36
frontend/src/components/tooltips/OCRTips.ts
Normal file
36
frontend/src/components/tooltips/OCRTips.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TooltipContent } from '../../types/tips';
|
||||||
|
|
||||||
|
export const OcrTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("ocr.tooltip.header.title", "OCR Settings Overview"),
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.mode.title", "OCR Mode"),
|
||||||
|
description: t("ocr.tooltip.mode.text", "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight."),
|
||||||
|
bullets: [
|
||||||
|
t("ocr.tooltip.mode.bullet1", "Auto skips pages that already contain text layers."),
|
||||||
|
t("ocr.tooltip.mode.bullet2", "Force re-OCRs every page and replaces all the text."),
|
||||||
|
t("ocr.tooltip.mode.bullet3", "Strict halts if any selectable text is found.")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.languages.title", "Languages"),
|
||||||
|
description: t("ocr.tooltip.languages.text", "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.output.title", "Output"),
|
||||||
|
description: t("ocr.tooltip.output.text", "Decide how you want the text output formatted:"),
|
||||||
|
bullets: [
|
||||||
|
t("ocr.tooltip.output.bullet1", "Searchable PDF embeds text behind the original image."),
|
||||||
|
t("ocr.tooltip.output.bullet2", "HOCR XML returns a structured machine-readable file."),
|
||||||
|
t("ocr.tooltip.output.bullet3", "Plain-text sidecar creates a separate .txt file with raw content.")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
47
frontend/src/contexts/SidebarContext.tsx
Normal file
47
frontend/src/contexts/SidebarContext.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React, { createContext, useContext, useState, useRef } from 'react';
|
||||||
|
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SidebarProvider({ children }: SidebarProviderProps) {
|
||||||
|
// All sidebar state management
|
||||||
|
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||||
|
const toolPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||||
|
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||||
|
const [readerMode, setReaderMode] = useState(false);
|
||||||
|
|
||||||
|
const sidebarState: SidebarState = {
|
||||||
|
sidebarsVisible,
|
||||||
|
leftPanelView,
|
||||||
|
readerMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarRefs: SidebarRefs = {
|
||||||
|
quickAccessRef,
|
||||||
|
toolPanelRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextValue: SidebarContextValue = {
|
||||||
|
sidebarState,
|
||||||
|
sidebarRefs,
|
||||||
|
setSidebarsVisible,
|
||||||
|
setLeftPanelView,
|
||||||
|
setReaderMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarContext(): SidebarContextValue {
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSidebarContext must be used within a SidebarProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
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 } 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 };
|
||||||
|
}
|
@ -2,11 +2,13 @@ import React, { useState, useCallback, useEffect, useRef } from "react";
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
|
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
|
||||||
import { useToolManagement } from "../hooks/useToolManagement";
|
import { useToolManagement } from "../hooks/useToolManagement";
|
||||||
import { useFileHandler } from "../hooks/useFileHandler";
|
import { useFileHandler } from "../hooks/useFileHandler";
|
||||||
import { Group, Box, Button } from "@mantine/core";
|
import { Group, Box, Button } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
|
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
|
||||||
import { PageEditorFunctions } from "../types/pageEditor";
|
import { PageEditorFunctions } from "../types/pageEditor";
|
||||||
|
import { SidebarRefs, SidebarState } from "../types/sidebar";
|
||||||
import rainbowStyles from '../styles/rainbow.module.css';
|
import rainbowStyles from '../styles/rainbow.module.css';
|
||||||
|
|
||||||
import ToolPicker from "../components/tools/ToolPicker";
|
import ToolPicker from "../components/tools/ToolPicker";
|
||||||
@ -20,9 +22,20 @@ import QuickAccessBar from "../components/shared/QuickAccessBar";
|
|||||||
import LandingPage from "../components/shared/LandingPage";
|
import LandingPage from "../components/shared/LandingPage";
|
||||||
import FileUploadModal from "../components/shared/FileUploadModal";
|
import FileUploadModal from "../components/shared/FileUploadModal";
|
||||||
|
|
||||||
|
|
||||||
function HomePageContent() {
|
function HomePageContent() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
const {
|
||||||
|
sidebarState,
|
||||||
|
sidebarRefs,
|
||||||
|
setSidebarsVisible,
|
||||||
|
setLeftPanelView,
|
||||||
|
setReaderMode
|
||||||
|
} = useSidebarContext();
|
||||||
|
|
||||||
|
const { sidebarsVisible, leftPanelView, readerMode } = sidebarState;
|
||||||
|
const { quickAccessRef, toolPanelRef } = sidebarRefs;
|
||||||
|
|
||||||
const fileContext = useFileContext();
|
const fileContext = useFileContext();
|
||||||
const { activeFiles, currentView, setCurrentView } = fileContext;
|
const { activeFiles, currentView, setCurrentView } = fileContext;
|
||||||
@ -37,9 +50,6 @@ function HomePageContent() {
|
|||||||
clearToolSelection,
|
clearToolSelection,
|
||||||
} = useToolManagement();
|
} = useToolManagement();
|
||||||
|
|
||||||
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
|
||||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
|
||||||
const [readerMode, setReaderMode] = useState(false);
|
|
||||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<PageEditorFunctions | null>(null);
|
const [pageEditorFunctions, setPageEditorFunctions] = useState<PageEditorFunctions | null>(null);
|
||||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||||
|
|
||||||
@ -92,16 +102,15 @@ function HomePageContent() {
|
|||||||
>
|
>
|
||||||
{/* Quick Access Bar */}
|
{/* Quick Access Bar */}
|
||||||
<QuickAccessBar
|
<QuickAccessBar
|
||||||
|
ref={quickAccessRef}
|
||||||
onToolsClick={handleQuickAccessTools}
|
onToolsClick={handleQuickAccessTools}
|
||||||
onReaderToggle={handleReaderToggle}
|
onReaderToggle={handleReaderToggle}
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
toolRegistry={toolRegistry}
|
|
||||||
leftPanelView={leftPanelView}
|
|
||||||
readerMode={readerMode}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Left: Tool Picker or Selected Tool Panel */}
|
{/* Left: Tool Picker or Selected Tool Panel */}
|
||||||
<div
|
<div
|
||||||
|
ref={toolPanelRef}
|
||||||
|
data-sidebar="tool-panel"
|
||||||
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
|
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
|
||||||
style={{
|
style={{
|
||||||
width: sidebarsVisible && !readerMode ? '14vw' : '0',
|
width: sidebarsVisible && !readerMode ? '14vw' : '0',
|
||||||
@ -279,7 +288,9 @@ function HomePageContent() {
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<FileSelectionProvider>
|
<FileSelectionProvider>
|
||||||
<HomePageContent />
|
<SidebarProvider>
|
||||||
|
<HomePageContent />
|
||||||
|
</SidebarProvider>
|
||||||
</FileSelectionProvider>
|
</FileSelectionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -103,6 +103,13 @@
|
|||||||
--icon-config-bg: #9CA3AF;
|
--icon-config-bg: #9CA3AF;
|
||||||
--icon-config-color: #FFFFFF;
|
--icon-config-color: #FFFFFF;
|
||||||
|
|
||||||
|
/* Colors for tooltips */
|
||||||
|
--tooltip-title-bg: #DBEFFF;
|
||||||
|
--tooltip-title-color: #31528E;
|
||||||
|
--tooltip-header-bg: #31528E;
|
||||||
|
--tooltip-header-color: white;
|
||||||
|
--tooltip-border: var(--border-default);
|
||||||
|
|
||||||
/* Inactive icon colors for light mode */
|
/* Inactive icon colors for light mode */
|
||||||
--icon-inactive-bg: #9CA3AF;
|
--icon-inactive-bg: #9CA3AF;
|
||||||
--icon-inactive-color: #FFFFFF;
|
--icon-inactive-color: #FFFFFF;
|
||||||
@ -201,6 +208,13 @@
|
|||||||
--icon-inactive-bg: #2A2F36;
|
--icon-inactive-bg: #2A2F36;
|
||||||
--icon-inactive-color: #6E7581;
|
--icon-inactive-color: #6E7581;
|
||||||
|
|
||||||
|
/* Dark mode tooltip colors */
|
||||||
|
--tooltip-title-bg: #4B525A;
|
||||||
|
--tooltip-title-color: #fff;
|
||||||
|
--tooltip-header-bg: var(--bg-raised);
|
||||||
|
--tooltip-header-color: var(--text-primary);
|
||||||
|
--tooltip-border: var(--border-default);
|
||||||
|
|
||||||
--accent-interactive: #ffffff;
|
--accent-interactive: #ffffff;
|
||||||
--text-instruction: #ffffff;
|
--text-instruction: #ffffff;
|
||||||
--text-brand: var(--color-gray-800);
|
--text-brand: var(--color-gray-800);
|
||||||
@ -224,6 +238,7 @@
|
|||||||
--drop-shadow-color: rgba(255, 255, 255, 0.08);
|
--drop-shadow-color: rgba(255, 255, 255, 0.08);
|
||||||
--drop-shadow-color-strong: rgba(255, 255, 255, 0.04);
|
--drop-shadow-color-strong: rgba(255, 255, 255, 0.04);
|
||||||
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04));
|
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04));
|
||||||
|
|
||||||
/* Adjust shadows for dark mode */
|
/* Adjust shadows for dark mode */
|
||||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
|
@ -17,6 +17,7 @@ import CompressSettings from "../components/tools/compress/CompressSettings";
|
|||||||
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
|
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
|
||||||
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
|
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
|
||||||
import { BaseToolProps } from "../types/tool";
|
import { BaseToolProps } from "../types/tool";
|
||||||
|
import { CompressTips } from "../components/tooltips/CompressTips";
|
||||||
|
|
||||||
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -25,6 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
|
|
||||||
const compressParams = useCompressParameters();
|
const compressParams = useCompressParameters();
|
||||||
const compressOperation = useCompressOperation();
|
const compressOperation = useCompressOperation();
|
||||||
|
const compressTips = CompressTips();
|
||||||
|
|
||||||
// Endpoint validation
|
// Endpoint validation
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf");
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf");
|
||||||
@ -104,6 +106,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
isCompleted={settingsCollapsed}
|
isCompleted={settingsCollapsed}
|
||||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
||||||
completedMessage={settingsCollapsed ? "Compression completed" : undefined}
|
completedMessage={settingsCollapsed ? "Compression completed" : undefined}
|
||||||
|
tooltip={compressTips}
|
||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<CompressSettings
|
<CompressSettings
|
||||||
|
@ -18,6 +18,7 @@ import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
|
|||||||
import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters";
|
import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters";
|
||||||
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
|
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
|
||||||
import { BaseToolProps } from "../types/tool";
|
import { BaseToolProps } from "../types/tool";
|
||||||
|
import { OcrTips } from "../components/tooltips/OCRTips";
|
||||||
|
|
||||||
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -26,6 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
|
|
||||||
const ocrParams = useOCRParameters();
|
const ocrParams = useOCRParameters();
|
||||||
const ocrOperation = useOCROperation();
|
const ocrOperation = useOCROperation();
|
||||||
|
const ocrTips = OcrTips();
|
||||||
|
|
||||||
// Step expansion state management
|
// Step expansion state management
|
||||||
const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files');
|
const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files');
|
||||||
@ -126,6 +128,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
setExpandedStep(expandedStep === 'settings' ? null : 'settings');
|
setExpandedStep(expandedStep === 'settings' ? null : 'settings');
|
||||||
}}
|
}}
|
||||||
completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined}
|
completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined}
|
||||||
|
tooltip={ocrTips}
|
||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<OCRSettings
|
<OCRSettings
|
||||||
|
46
frontend/src/types/sidebar.ts
Normal file
46
frontend/src/types/sidebar.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export interface SidebarState {
|
||||||
|
sidebarsVisible: boolean;
|
||||||
|
leftPanelView: 'toolPicker' | 'toolContent';
|
||||||
|
readerMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarRefs {
|
||||||
|
quickAccessRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
toolPanelRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarInfo {
|
||||||
|
rect: DOMRect | null;
|
||||||
|
isToolPanelActive: boolean;
|
||||||
|
sidebarState: SidebarState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context-related interfaces
|
||||||
|
export interface SidebarContextValue {
|
||||||
|
sidebarState: SidebarState;
|
||||||
|
sidebarRefs: SidebarRefs;
|
||||||
|
setSidebarsVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setLeftPanelView: React.Dispatch<React.SetStateAction<'toolPicker' | 'toolContent'>>;
|
||||||
|
setReaderMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickAccessBar related interfaces
|
||||||
|
export interface QuickAccessBarProps {
|
||||||
|
onToolsClick: () => void;
|
||||||
|
onReaderToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
tooltip: string;
|
||||||
|
isRound?: boolean;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
onClick: () => void;
|
||||||
|
type?: 'navigation' | 'modal' | 'action';
|
||||||
|
}
|
13
frontend/src/types/tips.ts
Normal file
13
frontend/src/types/tips.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export interface TooltipContent {
|
||||||
|
header?: {
|
||||||
|
title: string;
|
||||||
|
logo?: string | React.ReactNode;
|
||||||
|
};
|
||||||
|
tips?: Array<{
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
bullets?: string[];
|
||||||
|
body?: React.ReactNode;
|
||||||
|
}>;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
42
frontend/src/utils/genericUtils.ts
Normal file
42
frontend/src/utils/genericUtils.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* DOM utility functions for common operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps a value between a minimum and maximum
|
||||||
|
* @param value - The value to clamp
|
||||||
|
* @param min - The minimum allowed value
|
||||||
|
* @param max - The maximum allowed value
|
||||||
|
* @returns The clamped value
|
||||||
|
*/
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely adds an event listener with proper cleanup
|
||||||
|
* @param target - The target element or window/document
|
||||||
|
* @param event - The event type
|
||||||
|
* @param handler - The event handler function
|
||||||
|
* @param options - Event listener options
|
||||||
|
* @returns A cleanup function to remove the listener
|
||||||
|
*/
|
||||||
|
export function addEventListenerWithCleanup(
|
||||||
|
target: EventTarget,
|
||||||
|
event: string,
|
||||||
|
handler: EventListener,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): () => void {
|
||||||
|
target.addEventListener(event, handler, options);
|
||||||
|
return () => target.removeEventListener(event, handler, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a click event occurred outside of a specified element
|
||||||
|
* @param event - The click event
|
||||||
|
* @param element - The element to check against
|
||||||
|
* @returns True if the click was outside the element
|
||||||
|
*/
|
||||||
|
export function isClickOutside(event: MouseEvent, element: HTMLElement | null): boolean {
|
||||||
|
return element ? !element.contains(event.target as Node) : true;
|
||||||
|
}
|
34
frontend/src/utils/sidebarUtils.ts
Normal file
34
frontend/src/utils/sidebarUtils.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { SidebarRefs, SidebarState, SidebarInfo } from '../types/sidebar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the All tools sidebar information using React refs and state
|
||||||
|
* @param refs - Object containing refs to sidebar elements
|
||||||
|
* @param state - Current sidebar state
|
||||||
|
* @returns Object containing the sidebar rect and whether the tool panel is active
|
||||||
|
*/
|
||||||
|
export function getSidebarInfo(refs: SidebarRefs, state: SidebarState): SidebarInfo {
|
||||||
|
const { quickAccessRef, toolPanelRef } = refs;
|
||||||
|
const { sidebarsVisible, readerMode } = state;
|
||||||
|
|
||||||
|
// Determine if tool panel should be active based on state
|
||||||
|
const isToolPanelActive = sidebarsVisible && !readerMode;
|
||||||
|
|
||||||
|
let rect: DOMRect | null = null;
|
||||||
|
|
||||||
|
if (isToolPanelActive && toolPanelRef.current) {
|
||||||
|
// Tool panel is expanded: use its rect
|
||||||
|
rect = toolPanelRef.current.getBoundingClientRect();
|
||||||
|
} else if (quickAccessRef.current) {
|
||||||
|
// Fall back to quick access bar
|
||||||
|
// This probably isn't needed but if we ever have tooltips or modals that need to be positioned relative to the quick access bar, we can use this
|
||||||
|
rect = quickAccessRef.current.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rect,
|
||||||
|
isToolPanelActive,
|
||||||
|
sidebarState: state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user