= ({
+ content,
+ tips,
+}) => {
+ return (
+
+
+ {tips ? (
+ <>
+ {tips.map((tip, index) => (
+
+ {tip.title && (
+
+ {tip.title}
+
+ )}
+ {tip.description && (
+
+ )}
+ {tip.bullets && tip.bullets.length > 0 && (
+
+ {tip.bullets.map((bullet, bulletIndex) => (
+
+ ))}
+
+ )}
+ {tip.body && (
+
+ {tip.body}
+
+ )}
+
+ ))}
+ {content && (
+
+ {content}
+
+ )}
+ >
+ ) : (
+ content
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx
index eeedd8f3f..1551ea6c9 100644
--- a/frontend/src/components/tools/ToolPanel.tsx
+++ b/frontend/src/components/tools/ToolPanel.tsx
@@ -5,6 +5,7 @@ import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
import ToolRenderer from './ToolRenderer';
+import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
// No props needed - component uses context
@@ -12,6 +13,9 @@ import rainbowStyles from '../../styles/rainbow.module.css';
export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
+ const { sidebarRefs } = useSidebarContext();
+ const { toolPanelRef } = sidebarRefs;
+
// Use context-based hooks to eliminate prop drilling
const {
@@ -28,6 +32,8 @@ export default function ToolPanel() {
return (
{
+ if (tooltip && !isCollapsed) {
+ return (
+
+ e.stopPropagation()}>
+
+ {title}
+
+
+ gpp_maybe
+
+
+
+ );
+ }
+
+ return (
+
+ {title}
+
+ );
+};
+
const ToolStep = ({
title,
isVisible = true,
@@ -31,7 +73,8 @@ const ToolStep = ({
children,
completedMessage,
helpText,
- showNumber
+ showNumber,
+ tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
@@ -70,9 +113,7 @@ const ToolStep = ({
{stepNumber}
)}
-
- {title}
-
+ {renderTooltipTitle(title, tooltip, isCollapsed)}
{isCollapsed ? (
diff --git a/frontend/src/components/tooltips/CompressTips.ts b/frontend/src/components/tooltips/CompressTips.ts
new file mode 100644
index 000000000..2fb2a0777
--- /dev/null
+++ b/frontend/src/components/tooltips/CompressTips.ts
@@ -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.")
+ }
+ ]
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/components/tooltips/OCRTips.ts b/frontend/src/components/tooltips/OCRTips.ts
new file mode 100644
index 000000000..1002182f2
--- /dev/null
+++ b/frontend/src/components/tooltips/OCRTips.ts
@@ -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.")
+ ]
+ }
+ ]
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/contexts/SidebarContext.tsx b/frontend/src/contexts/SidebarContext.tsx
new file mode 100644
index 000000000..f09815c5c
--- /dev/null
+++ b/frontend/src/contexts/SidebarContext.tsx
@@ -0,0 +1,47 @@
+import React, { createContext, useContext, useState, useRef } from 'react';
+import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
+
+const SidebarContext = createContext(undefined);
+
+export function SidebarProvider({ children }: SidebarProviderProps) {
+ // All sidebar state management
+ const quickAccessRef = useRef(null);
+ const toolPanelRef = useRef(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 (
+
+ {children}
+
+ );
+}
+
+export function useSidebarContext(): SidebarContextValue {
+ const context = useContext(SidebarContext);
+ if (context === undefined) {
+ throw new Error('useSidebarContext must be used within a SidebarProvider');
+ }
+ return context;
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/useTooltipPosition.ts b/frontend/src/hooks/useTooltipPosition.ts
new file mode 100644
index 000000000..3651c1d47
--- /dev/null
+++ b/frontend/src/hooks/useTooltipPosition.ts
@@ -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;
+ tooltipRef: React.RefObject;
+ 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 };
+}
\ No newline at end of file
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index c9eb561b3..c32da34f8 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -4,14 +4,27 @@ import { useFileContext } from "../contexts/FileContext";
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
+import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
import ToolPanel from "../components/tools/ToolPanel";
import Workbench from "../components/layout/Workbench";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import FileUploadModal from "../components/shared/FileUploadModal";
+
function HomePageContent() {
const { t } = useTranslation();
+ const {
+ sidebarState,
+ sidebarRefs,
+ setSidebarsVisible,
+ setLeftPanelView,
+ setReaderMode
+ } = useSidebarContext();
+
+ const { sidebarsVisible, leftPanelView, readerMode } = sidebarState;
+ const { quickAccessRef, toolPanelRef } = sidebarRefs;
+
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
const { selectedTool } = useToolSelection();
@@ -34,7 +47,8 @@ function HomePageContent() {
gap={0}
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
>
-
+
@@ -47,7 +61,9 @@ export default function HomePage() {
return (
-
+
+
+
);
diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css
index 7cdb46c55..1cf3581c4 100644
--- a/frontend/src/styles/theme.css
+++ b/frontend/src/styles/theme.css
@@ -103,9 +103,40 @@
--icon-config-bg: #9CA3AF;
--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 */
--icon-inactive-bg: #9CA3AF;
--icon-inactive-color: #FFFFFF;
+
+ --accent-interactive: #4A90E2;
+ --text-instruction: #4A90E2;
+ --text-brand: var(--color-gray-700);
+ --text-brand-accent: #DC2626;
+
+ /* container */
+ --landing-paper-bg: var(--bg-surface);
+ --landing-inner-paper-bg: #EEF8FF;
+ --landing-inner-paper-border: #CDEAFF;
+ --landing-button-bg: var(--bg-surface);
+ --landing-button-color: var(--icon-tools-bg);
+ --landing-button-border: #E0F2F7;
+ --landing-button-hover-bg: rgb(251, 251, 251);
+
+ /* drop state */
+ --landing-drop-paper-bg: #E3F2FD;
+ --landing-drop-inner-paper-bg: #BBDEFB;
+ --landing-drop-inner-paper-border: #90CAF9;
+
+ /* shadows */
+ --drop-shadow-color: rgba(0, 0, 0, 0.08);
+ --drop-shadow-color-strong: rgba(0, 0, 0, 0.04);
+ --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04));
}
[data-mantine-color-scheme="dark"] {
@@ -177,6 +208,37 @@
--icon-inactive-bg: #2A2F36;
--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;
+ --text-instruction: #ffffff;
+ --text-brand: var(--color-gray-800);
+ --text-brand-accent: #EF4444;
+
+ /* container */
+ --landing-paper-bg: #171A1F;
+ --landing-inner-paper-bg: var(--bg-raised);
+ --landing-inner-paper-border: #2D3237;
+ --landing-button-bg: #2B3037;
+ --landing-button-color: #ffffff;
+ --landing-button-border: #2D3237;
+ --landing-button-hover-bg: #4c525b;
+
+ /* drop state */
+ --landing-drop-paper-bg: #1A2332;
+ --landing-drop-inner-paper-bg: #2A3441;
+ --landing-drop-inner-paper-border: #3A4451;
+
+ /* shadows */
+ --drop-shadow-color: rgba(255, 255, 255, 0.08);
+ --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));
+
/* Adjust shadows for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
@@ -185,6 +247,12 @@
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
}
+/* Dropzone drop state styling */
+[data-accept] .dropzone-inner {
+ background-color: var(--landing-drop-inner-paper-bg) !important;
+ border-color: var(--landing-drop-inner-paper-border) !important;
+}
+
/* Smooth transitions for theme switching */
* {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx
index cc0cd5cbc..f4b50b264 100644
--- a/frontend/src/tools/Compress.tsx
+++ b/frontend/src/tools/Compress.tsx
@@ -17,6 +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 { CompressTips } from "../components/tooltips/CompressTips";
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@@ -25,6 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const compressParams = useCompressParameters();
const compressOperation = useCompressOperation();
+ const compressTips = CompressTips();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf");
@@ -104,6 +106,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? "Compression completed" : undefined}
+ tooltip={compressTips}
>
{
const { t } = useTranslation();
@@ -26,6 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const ocrParams = useOCRParameters();
const ocrOperation = useOCROperation();
+ const ocrTips = OcrTips();
// Step expansion state management
const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files');
@@ -126,6 +128,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
setExpandedStep(expandedStep === 'settings' ? null : 'settings');
}}
completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined}
+ tooltip={ocrTips}
>
;
+ toolPanelRef: React.RefObject;
+}
+
+export interface SidebarInfo {
+ rect: DOMRect | null;
+ isToolPanelActive: boolean;
+ sidebarState: SidebarState;
+}
+
+// Context-related interfaces
+export interface SidebarContextValue {
+ sidebarState: SidebarState;
+ sidebarRefs: SidebarRefs;
+ setSidebarsVisible: React.Dispatch>;
+ setLeftPanelView: React.Dispatch>;
+ setReaderMode: React.Dispatch>;
+}
+
+export interface SidebarProviderProps {
+ children: React.ReactNode;
+}
+
+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';
+}
diff --git a/frontend/src/types/tips.ts b/frontend/src/types/tips.ts
new file mode 100644
index 000000000..58519e114
--- /dev/null
+++ b/frontend/src/types/tips.ts
@@ -0,0 +1,13 @@
+export interface TooltipContent {
+ header?: {
+ title: string;
+ logo?: string | React.ReactNode;
+ };
+ tips?: Array<{
+ title?: string;
+ description?: string;
+ bullets?: string[];
+ body?: React.ReactNode;
+ }>;
+ content?: React.ReactNode;
+}
\ No newline at end of file
diff --git a/frontend/src/utils/genericUtils.ts b/frontend/src/utils/genericUtils.ts
new file mode 100644
index 000000000..253346292
--- /dev/null
+++ b/frontend/src/utils/genericUtils.ts
@@ -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;
+}
diff --git a/frontend/src/utils/sidebarUtils.ts b/frontend/src/utils/sidebarUtils.ts
new file mode 100644
index 000000000..cef144971
--- /dev/null
+++ b/frontend/src/utils/sidebarUtils.ts
@@ -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
+ };
+}
+
+
\ No newline at end of file