Feature/v2/reader-and-multitool-navigation (#4514)

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh 2025-09-26 16:29:58 +01:00 committed by GitHub
parent c7e0ea5b5b
commit abc0988fdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 189 additions and 126 deletions

View File

@ -1,4 +1,10 @@
{ {
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
"unsavedChangesTitle": "Unsaved Changes",
"keepWorking": "Keep Working",
"discardChanges": "Discard Changes",
"applyAndContinue": "Apply & Continue",
"exportAndContinue": "Export & Continue",
"language": { "language": {
"direction": "ltr" "direction": "ltr"
}, },

View File

@ -149,7 +149,6 @@ export default function Workbench() {
<TopControls <TopControls
currentView={currentView} currentView={currentView}
setCurrentView={setCurrentView} setCurrentView={setCurrentView}
selectedToolKey={selectedToolId}
/> />
{/* Dismiss All Errors Button */} {/* Dismiss All Errors Button */}

View File

@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "../../contexts/FileContext"; import { useFileState, useFileActions } from "../../contexts/FileContext";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor"; import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import { pdfExportService } from "../../services/pdfExportService"; import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService"; import { documentManipulationService } from "../../services/documentManipulationService";
@ -36,6 +37,9 @@ const PageEditor = ({
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const { actions } = useFileActions(); const { actions } = useFileActions();
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
// Prefer IDs + selectors to avoid array identity churn // Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids; const activeFileIds = state.files.ids;
@ -82,6 +86,12 @@ const PageEditor = ({
updateUndoRedoState(); updateUndoRedoState();
}, [updateUndoRedoState]); }, [updateUndoRedoState]);
// Wrapper for executeCommand to track unsaved changes
const executeCommandWithTracking = useCallback((command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
}, [setHasUnsavedChanges]);
// Watch for container size changes to update split line positions // Watch for container size changes to update split line positions
useEffect(() => { useEffect(() => {
const container = gridContainerRef.current; const container = gridContainerRef.current;
@ -138,17 +148,16 @@ const PageEditor = ({
// DOM-first command handlers // DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => { const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
undoManagerRef.current.executeCommand(bulkRotateCommand); executeCommandWithTracking(bulkRotateCommand);
}, []); }, [executeCommandWithTracking]);
// Command factory functions for PageThumbnail // Command factory functions for PageThumbnail
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({ const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
execute: () => { execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
undoManagerRef.current.executeCommand(bulkRotateCommand);
} }
}), []); }), [executeCommandWithTracking]);
const createDeleteCommand = useCallback((pageIds: string[]) => ({ const createDeleteCommand = useCallback((pageIds: string[]) => ({
execute: () => { execute: () => {
@ -174,10 +183,10 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds), () => getPageNumbersFromIds(selectedPageIds),
closePdf closePdf
); );
undoManagerRef.current.executeCommand(deleteCommand); executeCommandWithTracking(deleteCommand);
} }
} }
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]); }), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const createSplitCommand = useCallback((position: number) => ({ const createSplitCommand = useCallback((position: number) => ({
execute: () => { execute: () => {
@ -186,9 +195,9 @@ const PageEditor = ({
() => splitPositions, () => splitPositions,
setSplitPositions setSplitPositions
); );
undoManagerRef.current.executeCommand(splitCommand); executeCommandWithTracking(splitCommand);
} }
}), [splitPositions]); }), [splitPositions, executeCommandWithTracking]);
// Command executor for PageThumbnail // Command executor for PageThumbnail
const executeCommand = useCallback((command: any) => { const executeCommand = useCallback((command: any) => {
@ -232,8 +241,8 @@ const PageEditor = ({
() => selectedPageNumbers, () => selectedPageNumbers,
closePdf closePdf
); );
undoManagerRef.current.executeCommand(deleteCommand); executeCommandWithTracking(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]); }, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
const handleDeletePage = useCallback((pageNumber: number) => { const handleDeletePage = useCallback((pageNumber: number) => {
if (!displayDocument) return; if (!displayDocument) return;
@ -251,8 +260,8 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds), () => getPageNumbersFromIds(selectedPageIds),
closePdf closePdf
); );
undoManagerRef.current.executeCommand(deleteCommand); executeCommandWithTracking(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]); }, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplit = useCallback(() => { const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return; if (!displayDocument || selectedPageIds.length === 0) return;
@ -298,8 +307,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)` : `Add ${selectedPositions.length - existingSplitsCount} split(s)`
}; };
undoManagerRef.current.executeCommand(smartSplitCommand); executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]); }, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplitAll = useCallback(() => { const handleSplitAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return; if (!displayDocument || selectedPageIds.length === 0) return;
@ -344,8 +353,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)` : `Add ${selectedPositions.length - existingSplitsCount} split(s)`
}; };
undoManagerRef.current.executeCommand(smartSplitCommand); executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]); }, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreak = useCallback(() => { const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return; if (!displayDocument || selectedPageIds.length === 0) return;
@ -358,8 +367,8 @@ const PageEditor = ({
() => displayDocument, () => displayDocument,
setEditedDocument setEditedDocument
); );
undoManagerRef.current.executeCommand(pageBreakCommand); executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]); }, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreakAll = useCallback(() => { const handlePageBreakAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return; if (!displayDocument || selectedPageIds.length === 0) return;
@ -372,8 +381,8 @@ const PageEditor = ({
() => displayDocument, () => displayDocument,
setEditedDocument setEditedDocument
); );
undoManagerRef.current.executeCommand(pageBreakCommand); executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]); }, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => { const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
if (!displayDocument || files.length === 0) return; if (!displayDocument || files.length === 0) return;
@ -416,8 +425,8 @@ const PageEditor = ({
() => displayDocument, () => displayDocument,
setEditedDocument setEditedDocument
); );
undoManagerRef.current.executeCommand(reorderCommand); executeCommandWithTracking(reorderCommand);
}, [displayDocument, getPageNumbersFromIds]); }, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
// Helper function to collect source files for multi-file export // Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<FileId, File> | null => { const getSourceFiles = useCallback((): Map<FileId, File> | null => {
@ -499,13 +508,14 @@ const PageEditor = ({
// Step 4: Download the result // Step 4: Download the result
pdfExportService.downloadFile(result.blob, result.filename); pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
setExportLoading(false); setExportLoading(false);
} catch (error) { } catch (error) {
console.error('Export failed:', error); console.error('Export failed:', error);
setExportLoading(false); setExportLoading(false);
} }
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]); }, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
const onExportAll = useCallback(async () => { const onExportAll = useCallback(async () => {
if (!displayDocument) return; if (!displayDocument) return;
@ -552,6 +562,7 @@ const PageEditor = ({
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip'); const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename); pdfExportService.downloadFile(zipBlob, zipFilename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
} else { } else {
// Single document - regular export // Single document - regular export
const sourceFiles = getSourceFiles(); const sourceFiles = getSourceFiles();
@ -570,6 +581,7 @@ const PageEditor = ({
); );
pdfExportService.downloadFile(result.blob, result.filename); pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
} }
setExportLoading(false); setExportLoading(false);
@ -577,7 +589,7 @@ const PageEditor = ({
console.error('Export failed:', error); console.error('Export failed:', error);
setExportLoading(false); setExportLoading(false);
} }
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]); }, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service // Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(() => { const applyChanges = useCallback(() => {
@ -779,7 +791,14 @@ const PageEditor = ({
)} )}
<NavigationWarningModal /> <NavigationWarningModal
onApplyAndContinue={async () => {
applyChanges();
}}
onExportAndContinue={async () => {
await onExportAll();
}}
/>
</Box> </Box>
); );
}; };

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core'; import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useNavigationGuard } from '../../contexts/NavigationContext'; import { useNavigationGuard } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
interface NavigationWarningModalProps { interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>; onApplyAndContinue?: () => Promise<void>;
@ -11,6 +12,8 @@ const NavigationWarningModal = ({
onApplyAndContinue, onApplyAndContinue,
onExportAndContinue onExportAndContinue
}: NavigationWarningModalProps) => { }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { const {
showNavigationWarning, showNavigationWarning,
hasUnsavedChanges, hasUnsavedChanges,
@ -28,7 +31,7 @@ const NavigationWarningModal = ({
confirmNavigation(); confirmNavigation();
}; };
const handleApplyAndContinue = async () => { const _handleApplyAndContinue = async () => {
if (onApplyAndContinue) { if (onApplyAndContinue) {
await onApplyAndContinue(); await onApplyAndContinue();
} }
@ -52,52 +55,56 @@ const NavigationWarningModal = ({
<Modal <Modal
opened={showNavigationWarning} opened={showNavigationWarning}
onClose={handleKeepWorking} onClose={handleKeepWorking}
title="Unsaved Changes" title={t("unsavedChangesTitle", "Unsaved Changes")}
centered centered
size="lg"
closeOnClickOutside={false} closeOnClickOutside={false}
closeOnEscape={false} closeOnEscape={false}
> >
<Stack gap="md"> <Stack gap="md">
<Text> <Text>
You have unsaved changes to your PDF. What would you like to do? {t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
</Text> </Text>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="gray"
onClick={handleKeepWorking}
>
Keep Working
</Button>
<Group justify="space-between" gap="sm">
<Button <Button
variant="light" variant="light"
color="red" color="red"
onClick={handleDiscardChanges} onClick={handleDiscardChanges}
> >
Discard Changes {t("discardChanges", "Discard Changes")}
</Button> </Button>
{onApplyAndContinue && ( <Group gap="sm">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={handleKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
{/* TODO:: Add this back in when it works */}
{/* {onApplyAndContinue && (
<Button <Button
variant="light" variant="light"
color="blue" color="blue"
onClick={handleApplyAndContinue} onClick={handleApplyAndContinue}
> >
Apply & Continue {t("applyAndContinue", "Apply & Continue")}
</Button> </Button>
)} )} */}
{onExportAndContinue && ( {onExportAndContinue && (
<Button <Button
color="green"
onClick={handleExportAndContinue} onClick={handleExportAndContinue}
> >
Export & Continue {t("exportAndContinue", "Export & Continue")}
</Button> </Button>
)} )}
</Group> </Group>
</Group>
</Stack> </Stack>
</Modal> </Modal>
); );

View File

@ -19,7 +19,7 @@ const viewOptionStyle = {
// Build view options showing text always // Build view options showing text always
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null, isToolSelected: boolean) => { const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
const viewerOption = { const viewerOption = {
label: ( label: (
<div style={viewOptionStyle as React.CSSProperties}> <div style={viewOptionStyle as React.CSSProperties}>
@ -75,7 +75,7 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
// Build options array conditionally // Build options array conditionally
return [ return [
viewerOption, viewerOption,
...(isToolSelected ? [] : [pageEditorOption]), pageEditorOption,
fileEditorOption, fileEditorOption,
]; ];
}; };
@ -83,19 +83,15 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
interface TopControlsProps { interface TopControlsProps {
currentView: WorkbenchType; currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void; setCurrentView: (view: WorkbenchType) => void;
selectedToolKey?: string | null;
} }
const TopControls = ({ const TopControls = ({
currentView, currentView,
setCurrentView, setCurrentView,
selectedToolKey,
}: TopControlsProps) => { }: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null); const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => { const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) { if (!isValidWorkbench(view)) {
return; return;
@ -122,7 +118,7 @@ const TopControls = ({
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none"> <div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]"> <div className="flex justify-center mt-[0.5rem]">
<SegmentedControl <SegmentedControl
data={createViewOptions(currentView, switchingTo, isToolSelected)} data={createViewOptions(currentView, switchingTo)}
value={currentView} value={currentView}
onChange={handleViewChange} onChange={handleViewChange}
color="blue" color="blue"

View File

@ -33,8 +33,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
const { getHomeNavigation } = useSidebarNavigation(); const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet) // Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
// Special case: multiTool should always show even when sidebars are hidden
const indicatorShouldShow = Boolean( const indicatorShouldShow = Boolean(
selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey) selectedToolKey &&
((leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)) ||
selectedToolKey === 'multiTool')
); );
// Local animation and hover state // Local animation and hover state

View File

@ -12,7 +12,7 @@ export const isNavButtonActive = (
isFilesModalOpen: boolean, isFilesModalOpen: boolean,
configModalOpen: boolean, configModalOpen: boolean,
selectedToolKey?: string | null, selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
): boolean => { ): boolean => {
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id; const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
const isActiveByContext = const isActiveByContext =
@ -35,7 +35,7 @@ export const getNavButtonStyle = (
isFilesModalOpen: boolean, isFilesModalOpen: boolean,
configModalOpen: boolean, configModalOpen: boolean,
selectedToolKey?: string | null, selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
) => { ) => {
const isActive = isNavButtonActive( const isActive = isNavButtonActive(
config, config,

View File

@ -7,6 +7,7 @@ import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext"; import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css'; import rainbowStyles from '../../styles/rainbow.module.css';
import { ScrollArea } from '@mantine/core'; import { ScrollArea } from '@mantine/core';
import { ToolId } from '../../types/toolId';
// No props needed - component uses context // No props needed - component uses context
@ -71,7 +72,7 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-y-auto"> <div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults <SearchResults
filteredTools={filteredTools} filteredTools={filteredTools}
onSelect={handleToolSelect} onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery} searchQuery={searchQuery}
/> />
</div> </div>
@ -80,7 +81,7 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-auto"> <div className="flex-1 flex flex-col overflow-auto">
<ToolPicker <ToolPicker
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolKey}
onSelect={handleToolSelect} onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools} filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)} isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/> />

View File

@ -17,7 +17,8 @@ interface ToolButtonProps {
} }
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => { const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
const isUnavailable = !tool.component && !tool.link; // Special case: read and multiTool are navigational tools that are always available
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const { getToolNavigation } = useToolNavigation(); const { getToolNavigation } = useToolNavigation();
const handleClick = (id: string) => { const handleClick = (id: string) => {

View File

@ -109,16 +109,34 @@ export const NavigationProvider: React.FC<{
const actions: NavigationContextActions = { const actions: NavigationContextActions = {
setWorkbench: useCallback((workbench: WorkbenchType) => { setWorkbench: useCallback((workbench: WorkbenchType) => {
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
const performWorkbenchChange = () => {
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
}, []), };
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
} else {
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
}
}, [state.workbench, state.hasUnsavedChanges]),
setSelectedTool: useCallback((toolId: ToolId | null) => { setSelectedTool: useCallback((toolId: ToolId | null) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } }); dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
}, []), }, []),
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => { setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
const performWorkbenchChange = () => {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } }); dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
}, []), };
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
} else {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
}
}, [state.workbench, state.hasUnsavedChanges]),
setHasUnsavedChanges: useCallback((hasChanges: boolean) => { setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });

View File

@ -17,7 +17,7 @@ import { filterToolRegistryByQuery } from '../utils/toolSearch';
interface ToolWorkflowState { interface ToolWorkflowState {
// UI State // UI State
sidebarsVisible: boolean; sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent'; leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
readerMode: boolean; readerMode: boolean;
// File/Preview State // File/Preview State
@ -31,7 +31,7 @@ interface ToolWorkflowState {
// Actions // Actions
type ToolWorkflowAction = type ToolWorkflowAction =
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean } | { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' } | { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' }
| { type: 'SET_READER_MODE'; payload: boolean } | { type: 'SET_READER_MODE'; payload: boolean }
| { type: 'SET_PREVIEW_FILE'; payload: File | null } | { type: 'SET_PREVIEW_FILE'; payload: File | null }
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null } | { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
@ -80,7 +80,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
// UI Actions // UI Actions
setSidebarsVisible: (visible: boolean) => void; setSidebarsVisible: (visible: boolean) => void;
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void; setLeftPanelView: (view: 'toolPicker' | 'toolContent' | 'hidden') => void;
setReaderMode: (mode: boolean) => void; setReaderMode: (mode: boolean) => void;
setPreviewFile: (file: File | null) => void; setPreviewFile: (file: File | null) => void;
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void; setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
@ -96,7 +96,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
resetTool: (toolId: string) => void; resetTool: (toolId: string) => void;
// Workflow Actions (compound actions) // Workflow Actions (compound actions)
handleToolSelect: (toolId: string) => void; handleToolSelect: (toolId: ToolId) => void;
handleBackToTools: () => void; handleBackToTools: () => void;
handleReaderToggle: () => void; handleReaderToggle: () => void;
@ -136,7 +136,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_SIDEBARS_VISIBLE', payload: visible }); dispatch({ type: 'SET_SIDEBARS_VISIBLE', payload: visible });
}, []); }, []);
const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent') => { const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent' | 'hidden') => {
dispatch({ type: 'SET_LEFT_PANEL_VIEW', payload: view }); dispatch({ type: 'SET_LEFT_PANEL_VIEW', payload: view });
}, []); }, []);
@ -180,7 +180,26 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
}, []); // Empty dependency array makes this stable }, []); // Empty dependency array makes this stable
// Workflow actions (compound actions that coordinate multiple state changes) // Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: string) => { const handleToolSelect = useCallback((toolId: ToolId) => {
// Handle read tool selection - should behave exactly like QuickAccessBar read button
if (toolId === 'read') {
setReaderMode(true);
actions.setSelectedTool('read');
actions.setWorkbench('viewer');
setSearchQuery('');
return;
}
// Handle multiTool selection - enable page editor workbench and hide left panel
if (toolId === 'multiTool') {
setReaderMode(false);
setLeftPanelView('hidden');
actions.setSelectedTool('multiTool');
actions.setWorkbench('pageEditor');
setSearchQuery('');
return;
}
// Set the selected tool and determine the appropriate workbench // Set the selected tool and determine the appropriate workbench
const validToolId = isValidToolId(toolId) ? toolId : null; const validToolId = isValidToolId(toolId) ? toolId : null;
actions.setSelectedTool(validToolId); actions.setSelectedTool(validToolId);
@ -195,19 +214,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Clear search query when selecting a tool // Clear search query when selecting a tool
setSearchQuery(''); setSearchQuery('');
// Handle view switching logic
if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') {
setLeftPanelView('toolPicker');
if (toolId === 'read' || toolId === 'view-pdf') {
setReaderMode(true);
} else {
setReaderMode(false);
}
} else {
setLeftPanelView('toolContent'); setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools setReaderMode(false); // Disable read mode when selecting tools
}
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]); }, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => { const handleBackToTools = useCallback(() => {
@ -227,8 +235,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
}, [toolRegistry, state.searchQuery]); }, [toolRegistry, state.searchQuery]);
const isPanelVisible = useMemo(() => const isPanelVisible = useMemo(() =>
state.sidebarsVisible && !state.readerMode, state.sidebarsVisible && !state.readerMode && state.leftPanelView !== 'hidden',
[state.sidebarsVisible, state.readerMode] [state.sidebarsVisible, state.readerMode, state.leftPanelView]
); );
// URL sync for proper tool navigation // URL sync for proper tool navigation

View File

@ -183,8 +183,32 @@ export function useFlatToolRegistry(): ToolRegistry {
return useMemo(() => { return useMemo(() => {
const allTools: ToolRegistry = { const allTools: ToolRegistry = {
// Recommended Tools in order
multiTool: {
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.multiTool.title", "Multi-Tool"),
component: null,
workbench: "pageEditor",
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
synonyms: getSynonyms(t, "multiTool"),
},
merge: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.merge.title", "Merge"),
component: Merge,
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
settingsComponent: MergeSettings,
synonyms: getSynonyms(t, "merge")
},
// Signing // Signing
certSign: { certSign: {
icon: <LocalIcon icon="workspace-premium-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="workspace-premium-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.certSign.title", "Certificate Sign"), name: t("home.certSign.title", "Certificate Sign"),
@ -792,30 +816,7 @@ export function useFlatToolRegistry(): ToolRegistry {
settingsComponent: ConvertSettings, settingsComponent: ConvertSettings,
synonyms: getSynonyms(t, "convert") synonyms: getSynonyms(t, "convert")
}, },
merge: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.merge.title", "Merge"),
component: Merge,
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
settingsComponent: MergeSettings,
synonyms: getSynonyms(t, "merge")
},
multiTool: {
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.multiTool.title", "Multi-Tool"),
component: null,
workbench: "pageEditor",
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
synonyms: getSynonyms(t, "multiTool"),
},
ocr: { ocr: {
icon: <LocalIcon icon="quick-reference-all-outline-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="quick-reference-all-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.ocr.title", "OCR"), name: t("home.ocr.title", "OCR"),

View File

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { ToolRegistryEntry, getToolUrlPath } from '../data/toolsTaxonomy'; import { ToolRegistryEntry, getToolUrlPath } from '../data/toolsTaxonomy';
import { useToolWorkflow } from '../contexts/ToolWorkflowContext'; import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
import { handleUnlessSpecialClick } from '../utils/clickHandlers'; import { handleUnlessSpecialClick } from '../utils/clickHandlers';
import { ToolId } from '../types/toolId';
export interface ToolNavigationProps { export interface ToolNavigationProps {
/** Full URL for the tool (for href attribute) */ /** Full URL for the tool (for href attribute) */
@ -34,7 +35,7 @@ export function useToolNavigation(): {
} }
// Use SPA navigation for internal tools // Use SPA navigation for internal tools
handleToolSelect(toolId); handleToolSelect(toolId as ToolId);
}); });
}; };

View File

@ -72,7 +72,10 @@ export function useToolSections(
const subcategoryId = s as SubcategoryId; const subcategoryId = s as SubcategoryId;
if (!quick[subcategoryId]) quick[subcategoryId] = []; if (!quick[subcategoryId]) quick[subcategoryId] = [];
// Only include ready tools (have a component or external link) in Quick Access // Only include ready tools (have a component or external link) in Quick Access
const readyTools = tools.filter(({ tool }) => tool.component !== null || !!tool.link); // Special case: read and multiTool are navigational tools that don't need components
const readyTools = tools.filter(({ tool, id }) =>
tool.component !== null || !!tool.link || id === 'read' || id === 'multiTool'
);
quick[subcategoryId].push(...readyTools); quick[subcategoryId].push(...readyTools);
}); });
} }

View File

@ -14,7 +14,7 @@ import { withBasePath } from '../constants/app';
*/ */
export function useNavigationUrlSync( export function useNavigationUrlSync(
selectedTool: ToolId | null, selectedTool: ToolId | null,
handleToolSelect: (toolId: string) => void, handleToolSelect: (toolId: ToolId) => void,
clearToolSelection: () => void, clearToolSelection: () => void,
registry: ToolRegistry, registry: ToolRegistry,
enableSync: boolean = true enableSync: boolean = true