Rework navigation

This commit is contained in:
Connor Yoh 2025-08-28 10:54:48 +01:00
parent 83400dc6a7
commit e59e73ceb0
22 changed files with 654 additions and 706 deletions

View File

@ -7,6 +7,7 @@ import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { SidebarProvider } from "./contexts/SidebarContext"; import { SidebarProvider } from "./contexts/SidebarContext";
import ErrorBoundary from "./components/shared/ErrorBoundary"; import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage"; import HomePage from "./pages/HomePage";
import { ScarfPixel } from "./components/ScarfPixel";
// Import global styles // Import global styles
import "./styles/tailwind.css"; import "./styles/tailwind.css";
@ -36,6 +37,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<FileContextProvider enableUrlSync={true} enablePersistence={true}> <FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider> <NavigationProvider>
<ScarfPixel />
<FilesModalProvider> <FilesModalProvider>
<ToolWorkflowProvider> <ToolWorkflowProvider>
<SidebarProvider> <SidebarProvider>

View File

@ -1,32 +1,29 @@
import { useLocation } from "react-router-dom";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useNavigationState } from "../contexts/NavigationContext";
export function ScarfPixel() { export function ScarfPixel() {
const location = useLocation(); const { workbench, selectedTool } = useNavigationState();
const lastUrlSent = useRef<string | null>(null); // helps with React 18 StrictMode in dev const lastUrlSent = useRef<string | null>(null); // helps with React 18 StrictMode in dev
useEffect(() => { useEffect(() => {
// Force reload of the tracking pixel on route change // Get current pathname from browser location
const pathname = window.location.pathname;
const url = 'https://static.scarf.sh/a.png?x-pxid=3c1d68de-8945-4e9f-873f-65320b6fabf7' const url = 'https://static.scarf.sh/a.png?x-pxid=3c1d68de-8945-4e9f-873f-65320b6fabf7'
+ '&path=' + encodeURIComponent(location.pathname) + '&path=' + encodeURIComponent(pathname)
+ '&t=' + Date.now(); // cache-buster + '&t=' + Date.now(); // cache-buster
console.log("ScarfPixel: Navigation change", { workbench, selectedTool, pathname });
// + '&machineType=' + machineType if (lastUrlSent.current !== url) {
// + '&appVersion=' + appVersion
// + '&licenseType=' + license
// + '&loginEnabled=' + loginEnabled;
console.log("ScarfPixel: reload " + location.pathname );
if (lastUrlSent.current !== url) {
lastUrlSent.current = url; lastUrlSent.current = url;
const img = new Image(); const img = new Image();
img.referrerPolicy = "no-referrer-when-downgrade"; // optional img.referrerPolicy = "no-referrer-when-downgrade"; // optional
img.src = url; img.src = url;
console.log("ScarfPixel: Fire to... " + location.pathname , url); console.log("ScarfPixel: Fire to... " + pathname, url);
} }
}, [location.pathname]); }, [workbench, selectedTool]); // Fire when navigation state changes
return null; // Nothing visible in UI return null; // Nothing visible in UI
} }

View File

@ -411,9 +411,9 @@ const FileEditor = ({
if (record) { if (record) {
// Set the file as selected in context and switch to viewer for preview // Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId]); setSelectedFiles([fileId]);
navActions.setMode('viewer'); navActions.setWorkbench('viewer');
} }
}, [activeFileRecords, setSelectedFiles, navActions.setMode]); }, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
const handleMergeFromHere = useCallback((fileId: string) => { const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = activeFileRecords.findIndex(r => r.id === fileId); const startIndex = activeFileRecords.findIndex(r => r.id === fileId);

View File

@ -6,13 +6,13 @@ import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState, useFileActions } from '../../contexts/FileContext'; import { useFileState, useFileActions } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext'; import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useToolManagement } from '../../hooks/useToolManagement';
import TopControls from '../shared/TopControls'; import TopControls from '../shared/TopControls';
import FileEditor from '../fileEditor/FileEditor'; import FileEditor from '../fileEditor/FileEditor';
import PageEditor from '../pageEditor/PageEditor'; import PageEditor from '../pageEditor/PageEditor';
import PageEditorControls from '../pageEditor/PageEditorControls'; import PageEditorControls from '../pageEditor/PageEditorControls';
import Viewer from '../viewer/Viewer'; import Viewer from '../viewer/Viewer';
import ToolRenderer from '../tools/ToolRenderer';
import LandingPage from '../shared/LandingPage'; import LandingPage from '../shared/LandingPage';
// No props needed - component uses contexts directly // No props needed - component uses contexts directly
@ -23,9 +23,9 @@ export default function Workbench() {
// Use context-based hooks to eliminate all prop drilling // Use context-based hooks to eliminate all prop drilling
const { state } = useFileState(); const { state } = useFileState();
const { actions } = useFileActions(); const { actions } = useFileActions();
const { currentMode: currentView } = useNavigationState(); const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions(); const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setMode; const setCurrentView = navActions.setWorkbench;
const activeFiles = state.files.ids; const activeFiles = state.files.ids;
const { const {
previewFile, previewFile,
@ -36,7 +36,14 @@ export default function Workbench() {
setSidebarsVisible setSidebarsVisible
} = useToolWorkflow(); } = useToolWorkflow();
const { selectedToolKey, selectedTool, handleToolSelect } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow();
// Get navigation state - this is the source of truth
const { selectedTool: selectedToolId } = useNavigationState();
// Get tool registry to look up selected tool
const { toolRegistry } = useToolManagement();
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addToActiveFiles } = useFileHandler(); const { addToActiveFiles } = useFileHandler();
const handlePreviewClose = () => { const handlePreviewClose = () => {
@ -69,11 +76,11 @@ export default function Workbench() {
case "fileEditor": case "fileEditor":
return ( return (
<FileEditor <FileEditor
toolMode={!!selectedToolKey} toolMode={!!selectedToolId}
showUpload={true} showUpload={true}
showBulkActions={!selectedToolKey} showBulkActions={!selectedToolId}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolKey && { {...(!selectedToolId && {
onOpenPageEditor: (file) => { onOpenPageEditor: (file) => {
setCurrentView("pageEditor"); setCurrentView("pageEditor");
}, },
@ -127,14 +134,6 @@ export default function Workbench() {
); );
default: default:
// Check if it's a tool view
if (selectedToolKey && selectedTool) {
return (
<ToolRenderer
selectedToolKey={selectedToolKey}
/>
);
}
return ( return (
<LandingPage/> <LandingPage/>
); );
@ -154,7 +153,7 @@ export default function Workbench() {
<TopControls <TopControls
currentView={currentView} currentView={currentView}
setCurrentView={setCurrentView} setCurrentView={setCurrentView}
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolId}
/> />
{/* Main content area */} {/* Main content area */}

View File

@ -6,7 +6,6 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { ModeType } from "../../contexts/NavigationContext";
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor"; import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { pdfExportService } from "../../services/pdfExportService"; import { pdfExportService } from "../../services/pdfExportService";
@ -83,7 +82,7 @@ const PageEditor = ({
// Grid container ref for positioning split indicators // Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
// State to trigger re-renders when container size changes // State to trigger re-renders when container size changes
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 });
@ -128,7 +127,7 @@ const PageEditor = ({
// Interface functions for parent component // Interface functions for parent component
const displayDocument = editedDocument || mergedPdfDocument; const displayDocument = editedDocument || mergedPdfDocument;
// Utility functions to convert between page IDs and page numbers // Utility functions to convert between page IDs and page numbers
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => { const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
if (!displayDocument) return []; if (!displayDocument) return [];
@ -137,7 +136,7 @@ const PageEditor = ({
return page?.pageNumber || 0; return page?.pageNumber || 0;
}).filter(num => num > 0); }).filter(num => num > 0);
}, [displayDocument]); }, [displayDocument]);
const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => { const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => {
if (!displayDocument) return []; if (!displayDocument) return [];
return pageNumbers.map(num => { return pageNumbers.map(num => {
@ -145,10 +144,10 @@ const PageEditor = ({
return page?.id || ''; return page?.id || '';
}).filter(id => id !== ''); }).filter(id => id !== '');
}, [displayDocument]); }, [displayDocument]);
// Convert selectedPageIds to numbers for components that still need numbers // Convert selectedPageIds to numbers for components that still need numbers
const selectedPageNumbers = useMemo(() => const selectedPageNumbers = useMemo(() =>
getPageNumbersFromIds(selectedPageIds), getPageNumbersFromIds(selectedPageIds),
[selectedPageIds, getPageNumbersFromIds] [selectedPageIds, getPageNumbersFromIds]
); );
@ -234,7 +233,7 @@ const PageEditor = ({
const handleRotate = useCallback((direction: 'left' | 'right') => { const handleRotate = useCallback((direction: 'left' | 'right') => {
if (!displayDocument || selectedPageIds.length === 0) return; if (!displayDocument || selectedPageIds.length === 0) return;
const rotation = direction === 'left' ? -90 : 90; const rotation = direction === 'left' ? -90 : 90;
handleRotatePages(selectedPageIds, rotation); handleRotatePages(selectedPageIds, rotation);
}, [displayDocument, selectedPageIds, handleRotatePages]); }, [displayDocument, selectedPageIds, handleRotatePages]);
@ -296,14 +295,14 @@ const PageEditor = ({
// Smart toggle logic: follow the majority, default to adding splits if equal // Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount; const noSplitsCount = selectedPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits // Remove splits only if majority already have splits
// If equal (50/50), default to adding splits // If equal (50/50), default to adding splits
const shouldRemoveSplits = existingSplitsCount > noSplitsCount; const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions); const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) { if (shouldRemoveSplits) {
// Remove splits from all selected positions // Remove splits from all selected positions
selectedPositions.forEach(pos => newSplitPositions.delete(pos)); selectedPositions.forEach(pos => newSplitPositions.delete(pos));
@ -316,8 +315,8 @@ const PageEditor = ({
const smartSplitCommand = { const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions), execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions), undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)` ? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)` : `Add ${selectedPositions.length - existingSplitsCount} split(s)`
}; };
@ -343,13 +342,13 @@ const PageEditor = ({
// Smart toggle logic: follow the majority, default to adding splits if equal // Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount; const noSplitsCount = selectedPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits // Remove splits only if majority already have splits
// If equal (50/50), default to adding splits // If equal (50/50), default to adding splits
const shouldRemoveSplits = existingSplitsCount > noSplitsCount; const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions); const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) { if (shouldRemoveSplits) {
// Remove splits from all selected positions // Remove splits from all selected positions
selectedPositions.forEach(pos => newSplitPositions.delete(pos)); selectedPositions.forEach(pos => newSplitPositions.delete(pos));
@ -362,8 +361,8 @@ const PageEditor = ({
const smartSplitCommand = { const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions), execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions), undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)` ? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)` : `Add ${selectedPositions.length - existingSplitsCount} split(s)`
}; };
@ -404,7 +403,7 @@ const PageEditor = ({
try { try {
const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage); const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage);
if (!targetPage) return; if (!targetPage) return;
await actions.addFiles(files, { insertAfterPageId: targetPage.id }); await actions.addFiles(files, { insertAfterPageId: targetPage.id });
} catch (error) { } catch (error) {
console.error('Failed to insert files:', error); console.error('Failed to insert files:', error);
@ -457,7 +456,7 @@ const PageEditor = ({
// Use multi-file export if we have multiple original files // Use multi-file export if we have multiple original files
const hasInsertedFiles = false; const hasInsertedFiles = false;
const hasMultipleOriginalFiles = activeFileIds.length > 1; const hasMultipleOriginalFiles = activeFileIds.length > 1;
if (!hasInsertedFiles && !hasMultipleOriginalFiles) { if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
return null; // Use single-file export method return null; // Use single-file export method
} }
@ -499,7 +498,7 @@ const PageEditor = ({
// Step 2: Use the already selected page IDs // Step 2: Use the already selected page IDs
// Filter to only include IDs that exist in the document with DOM state // Filter to only include IDs that exist in the document with DOM state
const validSelectedPageIds = selectedPageIds.filter(pageId => const validSelectedPageIds = selectedPageIds.filter(pageId =>
documentWithDOMState.pages.some(p => p.id === pageId) documentWithDOMState.pages.some(p => p.id === pageId)
); );
@ -551,11 +550,11 @@ const PageEditor = ({
const sourceFiles = getSourceFiles(); const sourceFiles = getSourceFiles();
const baseExportFilename = getExportFilename(); const baseExportFilename = getExportFilename();
const baseName = baseExportFilename.replace(/\.pdf$/i, ''); const baseName = baseExportFilename.replace(/\.pdf$/i, '');
for (let i = 0; i < processedDocuments.length; i++) { for (let i = 0; i < processedDocuments.length; i++) {
const doc = processedDocuments[i]; const doc = processedDocuments[i];
const partFilename = `${baseName}_part_${i + 1}.pdf`; const partFilename = `${baseName}_part_${i + 1}.pdf`;
const result = sourceFiles const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename }) ? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
: await pdfExportService.exportPDF(doc, [], { filename: partFilename }); : await pdfExportService.exportPDF(doc, [], { filename: partFilename });
@ -723,23 +722,23 @@ const PageEditor = ({
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
return Array.from(splitPositions).map((position) => { return Array.from(splitPositions).map((position) => {
// Calculate items per row using DragDropGrid's logic // Calculate items per row using DragDropGrid's logic
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP; const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap)); const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap));
// Calculate position within the grid (same as DragDropGrid) // Calculate position within the grid (same as DragDropGrid)
const row = Math.floor(position / itemsPerRow); const row = Math.floor(position / itemsPerRow);
const col = position % itemsPerRow; const col = position % itemsPerRow;
// Position split line between pages (after the current page) // Position split line between pages (after the current page)
// Calculate grid centering offset (same as DragDropGrid) // Calculate grid centering offset (same as DragDropGrid)
const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP; const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP;
const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2); const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2);
const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2); const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2);
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height) const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height)

View File

@ -25,7 +25,7 @@ export default function RightRail() {
const [csvInput, setCsvInput] = useState<string>(""); const [csvInput, setCsvInput] = useState<string>("");
// Navigation view // Navigation view
const { currentMode: currentView } = useNavigationState(); const { workbench: currentView } = useNavigationState();
// File state and selection // File state and selection
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();

View File

@ -5,7 +5,7 @@ import rainbowStyles from '../../styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote"; import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import { ModeType, isValidMode } from '../../contexts/NavigationContext'; import { WorkbenchType, isValidWorkbench } from '../../types/navigation';
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
const viewOptionStyle = { const viewOptionStyle = {
@ -19,7 +19,7 @@ const viewOptionStyle = {
// Build view options showing text only for current view; others icon-only with tooltip // Build view options showing text only for current view; others icon-only with tooltip
const createViewOptions = (currentView: ModeType, switchingTo: ModeType | null) => [ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
{ {
label: ( label: (
<div style={viewOptionStyle as React.CSSProperties}> <div style={viewOptionStyle as React.CSSProperties}>
@ -70,8 +70,8 @@ const createViewOptions = (currentView: ModeType, switchingTo: ModeType | null)
]; ];
interface TopControlsProps { interface TopControlsProps {
currentView: ModeType; currentView: WorkbenchType;
setCurrentView: (view: ModeType) => void; setCurrentView: (view: WorkbenchType) => void;
selectedToolKey?: string | null; selectedToolKey?: string | null;
} }
@ -81,25 +81,25 @@ const TopControls = ({
selectedToolKey, selectedToolKey,
}: TopControlsProps) => { }: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<ModeType | null>(null); const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const isToolSelected = selectedToolKey !== null; const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => { const handleViewChange = useCallback((view: string) => {
if (!isValidMode(view)) { if (!isValidWorkbench(view)) {
// Ignore invalid values defensively
return; return;
} }
const mode = view as ModeType;
const workbench = view;
// Show immediate feedback // Show immediate feedback
setSwitchingTo(mode as ModeType); setSwitchingTo(workbench);
// Defer the heavy view change to next frame so spinner can render // Defer the heavy view change to next frame so spinner can render
requestAnimationFrame(() => { requestAnimationFrame(() => {
// Give the spinner one more frame to show // Give the spinner one more frame to show
requestAnimationFrame(() => { requestAnimationFrame(() => {
setCurrentView(mode as ModeType); setCurrentView(workbench);
// Clear the loading state after view change completes // Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300); setTimeout(() => setSwitchingTo(null), 300);

View File

@ -1,6 +1,6 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react'; import React, { createContext, useContext, useReducer, useCallback } from 'react';
import { useNavigationUrlSync } from '../hooks/useUrlSync'; import { WorkbenchType, ToolId, getDefaultWorkbench } from '../types/navigation';
import { ModeType, isValidMode, getDefaultMode } from '../types/navigation'; import { useFlatToolRegistry } from '../data/useTranslatedToolRegistry';
/** /**
* NavigationContext - Complete navigation management system * NavigationContext - Complete navigation management system
@ -11,27 +11,38 @@ import { ModeType, isValidMode, getDefaultMode } from '../types/navigation';
*/ */
// Navigation state // Navigation state
interface NavigationState { interface NavigationContextState {
currentMode: ModeType; workbench: WorkbenchType;
selectedTool: ToolId | null;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null; pendingNavigation: (() => void) | null;
showNavigationWarning: boolean; showNavigationWarning: boolean;
selectedToolKey: string | null; // Add tool selection to navigation state
} }
// Navigation actions // Navigation actions
type NavigationAction = type NavigationAction =
| { type: 'SET_MODE'; payload: { mode: ModeType } } | { type: 'SET_WORKBENCH'; payload: { workbench: WorkbenchType } }
| { type: 'SET_SELECTED_TOOL'; payload: { toolId: ToolId | null } }
| { type: 'SET_TOOL_AND_WORKBENCH'; payload: { toolId: ToolId | null; workbench: WorkbenchType } }
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } } | { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } } | { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
| { type: 'SET_SELECTED_TOOL'; payload: { toolKey: string | null } };
// Navigation reducer // Navigation reducer
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => { const navigationReducer = (state: NavigationContextState, action: NavigationAction): NavigationContextState => {
switch (action.type) { switch (action.type) {
case 'SET_MODE': case 'SET_WORKBENCH':
return { ...state, currentMode: action.payload.mode }; return { ...state, workbench: action.payload.workbench };
case 'SET_SELECTED_TOOL':
return { ...state, selectedTool: action.payload.toolId };
case 'SET_TOOL_AND_WORKBENCH':
return {
...state,
selectedTool: action.payload.toolId,
workbench: action.payload.workbench
};
case 'SET_UNSAVED_CHANGES': case 'SET_UNSAVED_CHANGES':
return { ...state, hasUnsavedChanges: action.payload.hasChanges }; return { ...state, hasUnsavedChanges: action.payload.hasChanges };
@ -42,43 +53,41 @@ const navigationReducer = (state: NavigationState, action: NavigationAction): Na
case 'SHOW_NAVIGATION_WARNING': case 'SHOW_NAVIGATION_WARNING':
return { ...state, showNavigationWarning: action.payload.show }; return { ...state, showNavigationWarning: action.payload.show };
case 'SET_SELECTED_TOOL':
return { ...state, selectedToolKey: action.payload.toolKey };
default: default:
return state; return state;
} }
}; };
// Initial state // Initial state
const initialState: NavigationState = { const initialState: NavigationContextState = {
currentMode: getDefaultMode(), workbench: getDefaultWorkbench(),
selectedTool: null,
hasUnsavedChanges: false, hasUnsavedChanges: false,
pendingNavigation: null, pendingNavigation: null,
showNavigationWarning: false, showNavigationWarning: false
selectedToolKey: null
}; };
// Navigation context actions interface // Navigation context actions interface
export interface NavigationContextActions { export interface NavigationContextActions {
setMode: (mode: ModeType) => void; setWorkbench: (workbench: WorkbenchType) => void;
setSelectedTool: (toolId: ToolId | null) => void;
setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void;
showNavigationWarning: (show: boolean) => void; showNavigationWarning: (show: boolean) => void;
requestNavigation: (navigationFn: () => void) => void; requestNavigation: (navigationFn: () => void) => void;
confirmNavigation: () => void; confirmNavigation: () => void;
cancelNavigation: () => void; cancelNavigation: () => void;
selectTool: (toolKey: string) => void;
clearToolSelection: () => void; clearToolSelection: () => void;
handleToolSelect: (toolId: string) => void; handleToolSelect: (toolId: string) => void;
} }
// Split context values // Context state values
export interface NavigationContextStateValue { export interface NavigationContextStateValue {
currentMode: ModeType; workbench: WorkbenchType;
selectedTool: ToolId | null;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null; pendingNavigation: (() => void) | null;
showNavigationWarning: boolean; showNavigationWarning: boolean;
selectedToolKey: string | null;
} }
export interface NavigationContextActionsValue { export interface NavigationContextActionsValue {
@ -95,10 +104,19 @@ export const NavigationProvider: React.FC<{
enableUrlSync?: boolean; enableUrlSync?: boolean;
}> = ({ children, enableUrlSync = true }) => { }> = ({ children, enableUrlSync = true }) => {
const [state, dispatch] = useReducer(navigationReducer, initialState); const [state, dispatch] = useReducer(navigationReducer, initialState);
const toolRegistry = useFlatToolRegistry();
const actions: NavigationContextActions = { const actions: NavigationContextActions = {
setMode: useCallback((mode: ModeType) => { setWorkbench: useCallback((workbench: WorkbenchType) => {
dispatch({ type: 'SET_MODE', payload: { mode } }); dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
}, []),
setSelectedTool: useCallback((toolId: ToolId | null) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
}, []),
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
}, []), }, []),
setHasUnsavedChanges: useCallback((hasChanges: boolean) => { setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
@ -110,77 +128,64 @@ export const NavigationProvider: React.FC<{
}, []), }, []),
requestNavigation: useCallback((navigationFn: () => void) => { requestNavigation: useCallback((navigationFn: () => void) => {
// If no unsaved changes, navigate immediately
if (!state.hasUnsavedChanges) { if (!state.hasUnsavedChanges) {
navigationFn(); navigationFn();
return; return;
} }
// Otherwise, store the navigation and show warning
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
}, [state.hasUnsavedChanges]), }, [state.hasUnsavedChanges]),
confirmNavigation: useCallback(() => { confirmNavigation: useCallback(() => {
// Execute pending navigation
if (state.pendingNavigation) { if (state.pendingNavigation) {
state.pendingNavigation(); state.pendingNavigation();
} }
// Clear navigation state
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, [state.pendingNavigation]), }, [state.pendingNavigation]),
cancelNavigation: useCallback(() => { cancelNavigation: useCallback(() => {
// Clear navigation without executing
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, []), }, []),
selectTool: useCallback((toolKey: string) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey } });
}, []),
clearToolSelection: useCallback(() => { clearToolSelection: useCallback(() => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } }); dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
dispatch({ type: 'SET_MODE', payload: { mode: getDefaultMode() } });
}, []), }, []),
handleToolSelect: useCallback((toolId: string) => { handleToolSelect: useCallback((toolId: string) => {
// Handle special cases
if (toolId === 'allTools') { if (toolId === 'allTools') {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } }); dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
dispatch({ type: 'SET_MODE', payload: { mode: getDefaultMode() } });
return; return;
} }
// Special-case: if tool is a dedicated reader tool, enter reader mode
if (toolId === 'read' || toolId === 'view-pdf') { if (toolId === 'read' || toolId === 'view-pdf') {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } }); dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
return; return;
} }
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: toolId } }); // Look up the tool in the registry to get its proper workbench
dispatch({ type: 'SET_MODE', payload: { mode: 'fileEditor' as ModeType } }); const tool = toolRegistry[toolId];
}, []) const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
}, [toolRegistry])
}; };
const stateValue: NavigationContextStateValue = { const stateValue: NavigationContextStateValue = {
currentMode: state.currentMode, workbench: state.workbench,
selectedTool: state.selectedTool,
hasUnsavedChanges: state.hasUnsavedChanges, hasUnsavedChanges: state.hasUnsavedChanges,
pendingNavigation: state.pendingNavigation, pendingNavigation: state.pendingNavigation,
showNavigationWarning: state.showNavigationWarning, showNavigationWarning: state.showNavigationWarning
selectedToolKey: state.selectedToolKey
}; };
const actionsValue: NavigationContextActionsValue = { const actionsValue: NavigationContextActionsValue = {
actions actions
}; };
// Enable URL synchronization
useNavigationUrlSync(state.currentMode, actions.setMode, enableUrlSync);
return ( return (
<NavigationStateContext.Provider value={stateValue}> <NavigationStateContext.Provider value={stateValue}>
<NavigationActionsContext.Provider value={actionsValue}> <NavigationActionsContext.Provider value={actionsValue}>
@ -231,9 +236,6 @@ export const useNavigationGuard = () => {
}; };
}; };
// Re-export utility functions from types for backward compatibility
export { isValidMode, getDefaultMode, type ModeType } from '../types/navigation';
// TODO: This will be expanded for URL-based routing system // TODO: This will be expanded for URL-based routing system
// - URL parsing utilities // - URL parsing utilities
// - Route definitions // - Route definitions

View File

@ -7,8 +7,8 @@ import React, { createContext, useContext, useReducer, useCallback, useMemo } fr
import { useToolManagement } from '../hooks/useToolManagement'; import { useToolManagement } from '../hooks/useToolManagement';
import { PageEditorFunctions } from '../types/pageEditor'; import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry } from '../data/toolsTaxonomy'; import { ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
import { useNavigationActions, useNavigationState } from './NavigationContext'; import { useNavigationActions, useNavigationState } from './NavigationContext';
import { useNavigationUrlSync } from '../hooks/useUrlSync';
// State interface // State interface
interface ToolWorkflowState { interface ToolWorkflowState {
@ -124,7 +124,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
} = useToolManagement(); } = useToolManagement();
// Get selected tool from navigation context // Get selected tool from navigation context
const selectedTool = getSelectedTool(navigationState.selectedToolKey); const selectedTool = getSelectedTool(navigationState.selectedTool);
// UI Action creators // UI Action creators
const setSidebarsVisible = useCallback((visible: boolean) => { const setSidebarsVisible = useCallback((visible: boolean) => {
@ -142,7 +142,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const setPreviewFile = useCallback((file: File | null) => { const setPreviewFile = useCallback((file: File | null) => {
dispatch({ type: 'SET_PREVIEW_FILE', payload: file }); dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
if (file) { if (file) {
actions.setMode('viewer'); actions.setWorkbench('viewer');
} }
}, [actions]); }, [actions]);
@ -172,7 +172,16 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// 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: string) => {
actions.handleToolSelect(toolId); // Set the selected tool and determine the appropriate workbench
actions.setSelectedTool(toolId);
// Get the tool from registry to determine workbench
const tool = getSelectedTool(toolId);
if (tool && tool.workbench) {
actions.setWorkbench(tool.workbench);
} else {
actions.setWorkbench('fileEditor'); // Default workbench
}
// Clear search query when selecting a tool // Clear search query when selecting a tool
setSearchQuery(''); setSearchQuery('');
@ -189,13 +198,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setLeftPanelView('toolContent'); setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools setReaderMode(false); // Disable read mode when selecting tools
} }
}, [actions, setLeftPanelView, setReaderMode, setSearchQuery]); }, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => { const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker'); setLeftPanelView('toolPicker');
setReaderMode(false); setReaderMode(false);
actions.clearToolSelection(); actions.setSelectedTool(null);
}, [setLeftPanelView, setReaderMode, actions]); }, [setLeftPanelView, setReaderMode, actions.setSelectedTool]);
const handleReaderToggle = useCallback(() => { const handleReaderToggle = useCallback(() => {
setReaderMode(true); setReaderMode(true);
@ -214,14 +223,23 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
[state.sidebarsVisible, state.readerMode] [state.sidebarsVisible, state.readerMode]
); );
// Enable URL synchronization for tool selection // URL sync for proper tool navigation
useToolWorkflowUrlSync(navigationState.selectedToolKey, actions.selectTool, actions.clearToolSelection, true); useNavigationUrlSync(
navigationState.workbench,
navigationState.selectedTool,
actions.setWorkbench,
actions.setSelectedTool,
handleToolSelect,
() => actions.setSelectedTool(null),
toolRegistry,
true
);
// Properly memoized context value // Properly memoized context value
const contextValue = useMemo((): ToolWorkflowContextValue => ({ const contextValue = useMemo((): ToolWorkflowContextValue => ({
// State // State
...state, ...state,
selectedToolKey: navigationState.selectedToolKey, selectedToolKey: navigationState.selectedTool,
selectedTool, selectedTool,
toolRegistry, toolRegistry,
@ -232,8 +250,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setPreviewFile, setPreviewFile,
setPageEditorFunctions, setPageEditorFunctions,
setSearchQuery, setSearchQuery,
selectTool: actions.selectTool, selectTool: actions.setSelectedTool,
clearToolSelection: actions.clearToolSelection, clearToolSelection: () => actions.setSelectedTool(null),
// Tool Reset Actions // Tool Reset Actions
registerToolReset, registerToolReset,
@ -249,7 +267,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
isPanelVisible, isPanelVisible,
}), [ }), [
state, state,
navigationState.selectedToolKey, navigationState.selectedTool,
selectedTool, selectedTool,
toolRegistry, toolRegistry,
setSidebarsVisible, setSidebarsVisible,
@ -258,8 +276,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setPreviewFile, setPreviewFile,
setPageEditorFunctions, setPageEditorFunctions,
setSearchQuery, setSearchQuery,
actions.selectTool, actions.setSelectedTool,
actions.clearToolSelection,
registerToolReset, registerToolReset,
resetTool, resetTool,
handleToolSelect, handleToolSelect,

View File

@ -3,6 +3,7 @@ import React from 'react';
import { ToolOperationHook, ToolOperationConfig } from '../hooks/tools/shared/useToolOperation'; import { ToolOperationHook, ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
import { BaseToolProps } from '../types/tool'; import { BaseToolProps } from '../types/tool';
import { BaseParameters } from '../types/parameters'; import { BaseParameters } from '../types/parameters';
import { WorkbenchType } from '../types/navigation';
export enum SubcategoryId { export enum SubcategoryId {
SIGNING = 'signing', SIGNING = 'signing',
@ -28,7 +29,6 @@ export type ToolRegistryEntry = {
icon: React.ReactNode; icon: React.ReactNode;
name: string; name: string;
component: React.ComponentType<BaseToolProps> | null; component: React.ComponentType<BaseToolProps> | null;
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
description: string; description: string;
categoryId: ToolCategoryId; categoryId: ToolCategoryId;
subcategoryId: SubcategoryId; subcategoryId: SubcategoryId;
@ -37,6 +37,10 @@ export type ToolRegistryEntry = {
endpoints?: string[]; endpoints?: string[];
link?: string; link?: string;
type?: string; type?: string;
// URL path for routing (e.g., '/split-pdfs', '/compress-pdf')
urlPath?: string;
// Workbench type for navigation
workbench?: WorkbenchType;
// Operation configuration for automation // Operation configuration for automation
operationConfig?: ToolOperationConfig<any>; operationConfig?: ToolOperationConfig<any>;
// Settings component for automation configuration // Settings component for automation configuration
@ -107,3 +111,30 @@ export const getAllApplicationEndpoints = (
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : []; const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
return Array.from(new Set([...toolEp, ...convEp])); return Array.from(new Set([...toolEp, ...convEp]));
}; };
/**
* Default workbench for tools that don't specify one
* Returns null to trigger the default case in Workbench component (ToolRenderer)
*/
export const getDefaultToolWorkbench = (): WorkbenchType => 'fileEditor';
/**
* Get workbench type for a tool
*/
export const getToolWorkbench = (tool: ToolRegistryEntry): WorkbenchType => {
return tool.workbench || getDefaultToolWorkbench();
};
/**
* Get URL path for a tool
*/
export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string => {
return tool.urlPath || `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
};
/**
* Check if a tool ID exists in the registry
*/
export const isValidToolId = (toolId: string, registry: ToolRegistry): boolean => {
return toolId in registry;
};

File diff suppressed because it is too large Load Diff

View File

@ -45,16 +45,16 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
export function useSuggestedTools(): SuggestedTool[] { export function useSuggestedTools(): SuggestedTool[] {
const { actions } = useNavigationActions(); const { actions } = useNavigationActions();
const { selectedToolKey } = useNavigationState(); const { selectedTool } = useNavigationState();
return useMemo(() => { return useMemo(() => {
// Filter out the current tool // Filter out the current tool
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedToolKey); const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedTool);
// Add navigation function to each tool // Add navigation function to each tool
return filteredTools.map(tool => ({ return filteredTools.map(tool => ({
...tool, ...tool,
navigate: () => actions.handleToolSelect(tool.id) navigate: () => actions.setSelectedTool(tool.id)
})); }));
}, [selectedToolKey, actions]); }, [selectedTool, actions]);
} }

View File

@ -1,123 +1,90 @@
/** /**
* URL synchronization hooks for tool routing * URL synchronization hooks for tool routing with registry support
*/ */
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback } from 'react';
import { ModeType } from '../types/navigation'; import { WorkbenchType, ToolId, ToolRoute } from '../types/navigation';
import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting'; import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting';
import { ToolRegistry } from '../data/toolsTaxonomy';
/** /**
* Hook to sync navigation mode with URL * Hook to sync workbench and tool with URL using registry
*/ */
export function useNavigationUrlSync( export function useNavigationUrlSync(
currentMode: ModeType, workbench: WorkbenchType,
setMode: (mode: ModeType) => void, selectedTool: ToolId | null,
setWorkbench: (workbench: WorkbenchType) => void,
setSelectedTool: (toolId: ToolId | null) => void,
handleToolSelect: (toolId: string) => void,
clearToolSelection: () => void,
registry: ToolRegistry,
enableSync: boolean = true enableSync: boolean = true
) { ) {
// Initialize mode from URL on mount // Initialize workbench and tool from URL on mount
useEffect(() => { useEffect(() => {
if (!enableSync) return; if (!enableSync) return;
const route = parseToolRoute(); const route = parseToolRoute(registry);
if (route.mode !== currentMode) { if (route.toolId !== selectedTool) {
setMode(route.mode); if (route.toolId) {
handleToolSelect(route.toolId);
} else {
clearToolSelection();
}
} }
}, []); // Only run on mount }, []); // Only run on mount
// Update URL when mode changes // Update URL when tool or workbench changes
useEffect(() => { useEffect(() => {
if (!enableSync) return; if (!enableSync) return;
// Only update URL for actual tool modes, not internal UI modes if (selectedTool) {
// URL clearing is handled by useToolWorkflowUrlSync when selectedToolKey becomes null updateToolRoute(selectedTool, registry);
if (currentMode !== 'fileEditor' && currentMode !== 'pageEditor' && currentMode !== 'viewer') { } else {
updateToolRoute(currentMode, currentMode); // Clear URL when no tool is selected
clearToolRoute();
} }
}, [currentMode, enableSync]); }, [selectedTool, registry, enableSync]);
// Handle browser back/forward navigation // Handle browser back/forward navigation
useEffect(() => { useEffect(() => {
if (!enableSync) return; if (!enableSync) return;
const handlePopState = () => { const handlePopState = () => {
const route = parseToolRoute(); const route = parseToolRoute(registry);
if (route.mode !== currentMode) { if (route.toolId !== selectedTool) {
setMode(route.mode); if (route.toolId) {
handleToolSelect(route.toolId);
} else {
clearToolSelection();
}
} }
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState);
}, [currentMode, setMode, enableSync]); }, [selectedTool, handleToolSelect, clearToolSelection, registry, enableSync]);
} }
/** /**
* Hook to sync tool workflow with URL * Hook to programmatically navigate to tools with registry support
*/ */
export function useToolWorkflowUrlSync( export function useToolNavigation(registry: ToolRegistry) {
selectedToolKey: string | null, const navigateToTool = useCallback((toolId: ToolId) => {
selectTool: (toolKey: string) => void, updateToolRoute(toolId, registry);
clearTool: () => void,
enableSync: boolean = true
) {
// Initialize tool from URL on mount
useEffect(() => {
if (!enableSync) return;
const route = parseToolRoute();
if (route.toolKey && route.toolKey !== selectedToolKey) {
selectTool(route.toolKey);
} else if (!route.toolKey && selectedToolKey) {
clearTool();
}
}, []); // Only run on mount
// Update URL when tool changes
useEffect(() => {
if (!enableSync) return;
if (selectedToolKey) {
const route = parseToolRoute();
if (route.toolKey !== selectedToolKey) {
updateToolRoute(selectedToolKey as ModeType, selectedToolKey);
}
} else {
// Clear URL when no tool is selected - always clear regardless of current URL
clearToolRoute();
}
}, [selectedToolKey, enableSync]);
}
/**
* Hook to get current URL route information
*/
export function useCurrentRoute() {
const getCurrentRoute = useCallback(() => {
return parseToolRoute();
}, []);
return getCurrentRoute;
}
/**
* Hook to programmatically navigate to tools
*/
export function useToolNavigation() {
const navigateToTool = useCallback((toolKey: string) => {
updateToolRoute(toolKey as ModeType, toolKey);
// Dispatch a custom event to notify other components // Dispatch a custom event to notify other components
window.dispatchEvent(new CustomEvent('toolNavigation', { window.dispatchEvent(new CustomEvent('toolNavigation', {
detail: { toolKey } detail: { toolId }
})); }));
}, []); }, [registry]);
const navigateToHome = useCallback(() => { const navigateToHome = useCallback(() => {
clearToolRoute(); clearToolRoute();
// Dispatch a custom event to notify other components // Dispatch a custom event to notify other components
window.dispatchEvent(new CustomEvent('toolNavigation', { window.dispatchEvent(new CustomEvent('toolNavigation', {
detail: { toolKey: null } detail: { toolId: null }
})); }));
}, []); }, []);
@ -126,3 +93,14 @@ export function useToolNavigation() {
navigateToHome navigateToHome
}; };
} }
/**
* Hook to get current URL route information with registry support
*/
export function useCurrentRoute(registry: ToolRegistry) {
const getCurrentRoute = useCallback(() => {
return parseToolRoute(registry);
}, [registry]);
return getCurrentRoute;
}

View File

@ -8,7 +8,6 @@ import { BrowserRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import './i18n'; // Initialize i18next import './i18n'; // Initialize i18next
import { PostHogProvider } from 'posthog-js/react'; import { PostHogProvider } from 'posthog-js/react';
import { ScarfPixel } from './components/ScarfPixel';
// Compute initial color scheme // Compute initial color scheme
function getInitialScheme(): 'light' | 'dark' { function getInitialScheme(): 'light' | 'dark' {
@ -40,7 +39,6 @@ root.render(
}} }}
> >
<BrowserRouter> <BrowserRouter>
<ScarfPixel />
<App /> <App />
</BrowserRouter> </BrowserRouter>
</PostHogProvider> </PostHogProvider>

View File

@ -2,7 +2,7 @@ import React, { useState, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useFileSelection } from "../contexts/FileContext"; import { useFileSelection } from "../contexts/FileContext";
import { useNavigation } from "../contexts/NavigationContext"; import { useNavigationActions } from "../contexts/NavigationContext";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext"; import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
@ -21,7 +21,7 @@ import { AUTOMATION_STEPS } from "../constants/automation";
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedFiles } = useFileSelection(); const { selectedFiles } = useFileSelection();
const { setMode } = useNavigation(); const { actions } = useNavigationActions();
const { registerToolReset } = useToolWorkflow(); const { registerToolReset } = useToolWorkflow();
const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION); const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION);
@ -223,7 +223,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
title: t('automate.reviewTitle', 'Automation Results'), title: t('automate.reviewTitle', 'Automation Results'),
onFileClick: (file: File) => { onFileClick: (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
setMode('viewer'); actions.setWorkbench('viewer');
} }
} }
}); });

View File

@ -43,7 +43,7 @@ const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolP
const handleThumbnailClick = (file: File) => { const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "removeCertificateSign"); sessionStorage.setItem("previousMode", "removeCertificateSign");
actions.setMode("viewer"); actions.setWorkbench("viewer");
}; };
const handleSettingsReset = () => { const handleSettingsReset = () => {

View File

@ -43,7 +43,7 @@ const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleThumbnailClick = (file: File) => { const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "repair"); sessionStorage.setItem("previousMode", "repair");
actions.setMode("viewer"); actions.setWorkbench("viewer");
}; };
const handleSettingsReset = () => { const handleSettingsReset = () => {

View File

@ -43,7 +43,7 @@ const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps)
const handleThumbnailClick = (file: File) => { const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "single-large-page"); sessionStorage.setItem("previousMode", "single-large-page");
actions.setMode("viewer"); actions.setWorkbench("viewer");
}; };
const handleSettingsReset = () => { const handleSettingsReset = () => {

View File

@ -43,7 +43,7 @@ const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
const handleThumbnailClick = (file: File) => { const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "unlockPdfForms"); sessionStorage.setItem("previousMode", "unlockPdfForms");
actions.setMode("viewer"); actions.setWorkbench("viewer");
}; };
const handleSettingsReset = () => { const handleSettingsReset = () => {

View File

@ -1,42 +1,31 @@
/** /**
* Shared navigation types to avoid circular dependencies * Navigation types for workbench and tool separation
*/ */
// Navigation mode types - complete list to match contexts // Define workbench values once as source of truth
export type ModeType = const WORKBENCH_TYPES = ['viewer', 'pageEditor', 'fileEditor'] as const;
| 'viewer'
| 'pageEditor'
| 'fileEditor'
| 'merge'
| 'split'
| 'compress'
| 'ocr'
| 'convert'
| 'sanitize'
| 'addPassword'
| 'changePermissions'
| 'addWatermark'
| 'removePassword'
| 'single-large-page'
| 'repair'
| 'unlockPdfForms'
| 'removeCertificateSign';
// Utility functions for mode handling // Workbench types - how the user interacts with content
export const isValidMode = (mode: string): mode is ModeType => { export type WorkbenchType = typeof WORKBENCH_TYPES[number];
const validModes: ModeType[] = [
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split', // Tool identity - what PDF operation we're performing (derived from registry)
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', export type ToolId = string;
'sanitize', 'addWatermark', 'removePassword', 'single-large-page',
'repair', 'unlockPdfForms', 'removeCertificateSign' // Navigation state
]; export interface NavigationState {
return validModes.includes(mode as ModeType); workbench: WorkbenchType;
selectedTool: ToolId | null;
}
export const getDefaultWorkbench = (): WorkbenchType => 'fileEditor';
// Type guard using the same source of truth - no duplication
export const isValidWorkbench = (value: string): value is WorkbenchType => {
return WORKBENCH_TYPES.includes(value as WorkbenchType);
}; };
export const getDefaultMode = (): ModeType => 'fileEditor';
// Route parsing result // Route parsing result
export interface ToolRoute { export interface ToolRoute {
mode: ModeType; workbench: WorkbenchType;
toolKey: string | null; toolId: ToolId | null;
} }

View File

@ -2,10 +2,11 @@
* Navigation action interfaces to break circular dependencies * Navigation action interfaces to break circular dependencies
*/ */
import { ModeType } from './navigation'; import { WorkbenchType, ToolId } from './navigation';
export interface NavigationActions { export interface NavigationActions {
setMode: (mode: ModeType) => void; setWorkbench: (workbench: WorkbenchType) => void;
setSelectedTool: (toolId: ToolId | null) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void;
showNavigationWarning: (show: boolean) => void; showNavigationWarning: (show: boolean) => void;
requestNavigation: (navigationFn: () => void) => void; requestNavigation: (navigationFn: () => void) => void;
@ -14,7 +15,8 @@ export interface NavigationActions {
} }
export interface NavigationState { export interface NavigationState {
currentMode: ModeType; workbench: WorkbenchType;
selectedTool: ToolId | null;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null; pendingNavigation: (() => void) | null;
showNavigationWarning: boolean; showNavigationWarning: boolean;

View File

@ -1,119 +1,64 @@
/** /**
* URL routing utilities for tool navigation * URL routing utilities for tool navigation with registry support
* Provides clean URL routing for the V2 tool system
*/ */
import { ModeType, isValidMode as isValidModeType, getDefaultMode, ToolRoute } from '../types/navigation'; import {
ToolId,
ToolRoute,
getDefaultWorkbench
} from '../types/navigation';
import { ToolRegistry, getToolWorkbench, getToolUrlPath, isValidToolId } from '../data/toolsTaxonomy';
/** /**
* Parse the current URL to extract tool routing information * Parse the current URL to extract tool routing information
*/ */
export function parseToolRoute(): ToolRoute { export function parseToolRoute(registry: ToolRegistry): ToolRoute {
const path = window.location.pathname; const path = window.location.pathname;
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
// Extract tool from URL path (e.g., /split-pdf -> split) // Try to find tool by URL path
const toolMatch = path.match(/\/([a-zA-Z-]+)(?:-pdf)?$/); for (const [toolId, tool] of Object.entries(registry)) {
if (toolMatch) { const toolUrlPath = getToolUrlPath(toolId, tool);
const toolKey = toolMatch[1].toLowerCase(); if (path === toolUrlPath) {
// Map URL paths to tool keys and modes (excluding internal UI modes)
const toolMappings: Record<string, { mode: ModeType; toolKey: string }> = {
'split-pdfs': { mode: 'split', toolKey: 'split' },
'split': { mode: 'split', toolKey: 'split' },
'merge-pdfs': { mode: 'merge', toolKey: 'merge' },
'compress-pdf': { mode: 'compress', toolKey: 'compress' },
'convert': { mode: 'convert', toolKey: 'convert' },
'convert-pdf': { mode: 'convert', toolKey: 'convert' },
'file-to-pdf': { mode: 'convert', toolKey: 'convert' },
'eml-to-pdf': { mode: 'convert', toolKey: 'convert' },
'html-to-pdf': { mode: 'convert', toolKey: 'convert' },
'markdown-to-pdf': { mode: 'convert', toolKey: 'convert' },
'pdf-to-csv': { mode: 'convert', toolKey: 'convert' },
'pdf-to-img': { mode: 'convert', toolKey: 'convert' },
'pdf-to-markdown': { mode: 'convert', toolKey: 'convert' },
'pdf-to-pdfa': { mode: 'convert', toolKey: 'convert' },
'pdf-to-word': { mode: 'convert', toolKey: 'convert' },
'pdf-to-xml': { mode: 'convert', toolKey: 'convert' },
'add-password': { mode: 'addPassword', toolKey: 'addPassword' },
'change-permissions': { mode: 'changePermissions', toolKey: 'changePermissions' },
'sanitize-pdf': { mode: 'sanitize', toolKey: 'sanitize' },
'ocr': { mode: 'ocr', toolKey: 'ocr' },
'ocr-pdf': { mode: 'ocr', toolKey: 'ocr' },
'add-watermark': { mode: 'addWatermark', toolKey: 'addWatermark' },
'remove-password': { mode: 'removePassword', toolKey: 'removePassword' },
'single-large-page': { mode: 'single-large-page', toolKey: 'single-large-page' },
'repair': { mode: 'repair', toolKey: 'repair' },
'unlock-pdf-forms': { mode: 'unlockPdfForms', toolKey: 'unlockPdfForms' },
'remove-certificate-sign': { mode: 'removeCertificateSign', toolKey: 'removeCertificateSign' },
'remove-cert-sign': { mode: 'removeCertificateSign', toolKey: 'removeCertificateSign' }
};
const mapping = toolMappings[toolKey];
if (mapping) {
return { return {
mode: mapping.mode, workbench: getToolWorkbench(tool),
toolKey: mapping.toolKey toolId
}; };
} }
} }
// Check for query parameter fallback (e.g., ?tool=split) // Check for query parameter fallback (e.g., ?tool=split)
const toolParam = searchParams.get('tool'); const toolParam = searchParams.get('tool');
if (toolParam && isValidModeType(toolParam)) { if (toolParam && isValidToolId(toolParam, registry)) {
const tool = registry[toolParam];
return { return {
mode: toolParam as ModeType, workbench: getToolWorkbench(tool),
toolKey: toolParam toolId: toolParam
}; };
} }
// Default to page editor for home page // Default to fileEditor workbench for home page
return { return {
mode: getDefaultMode(), workbench: getDefaultWorkbench(),
toolKey: null toolId: null
}; };
} }
/** /**
* Update the URL to reflect the current tool selection * Update the URL to reflect the current tool selection
* Internal UI modes (viewer, fileEditor, pageEditor) don't get URLs
*/ */
export function updateToolRoute(mode: ModeType, toolKey?: string): void { export function updateToolRoute(toolId: ToolId, registry: ToolRegistry): void {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
// Don't create URLs for internal UI modes const tool = registry[toolId];
if (mode === 'viewer' || mode === 'fileEditor' || mode === 'pageEditor') { if (!tool) {
// If we're switching to an internal mode, clear any existing tool URL console.warn(`Tool ${toolId} not found in registry`);
if (currentPath !== '/') {
clearToolRoute();
}
return; return;
} }
let newPath = '/';
// Map modes to URL paths (only for actual tools) const newPath = getToolUrlPath(toolId, tool);
if (toolKey) {
const pathMappings: Record<string, string> = {
'split': '/split-pdfs',
'merge': '/merge-pdf',
'compress': '/compress-pdf',
'convert': '/convert-pdf',
'addPassword': '/add-password-pdf',
'changePermissions': '/change-permissions-pdf',
'sanitize': '/sanitize-pdf',
'ocr': '/ocr-pdf',
'addWatermark': '/watermark',
'removePassword': '/remove-password',
'single-large-page': '/single-large-page',
'repair': '/repair',
'unlockPdfForms': '/unlock-pdf-forms',
'removeCertificateSign': '/remove-certificate-sign'
};
newPath = pathMappings[toolKey] || `/${toolKey}`;
}
// Remove tool query parameter since we're using path-based routing // Remove tool query parameter since we're using path-based routing
searchParams.delete('tool'); searchParams.delete('tool');
@ -142,58 +87,25 @@ export function clearToolRoute(): void {
} }
/** /**
* Get clean tool name for display purposes * Get clean tool name for display purposes using registry
*/ */
export function getToolDisplayName(toolKey: string): string { export function getToolDisplayName(toolId: ToolId, registry: ToolRegistry): string {
const displayNames: Record<string, string> = { const tool = registry[toolId];
'split': 'Split PDF', return tool ? tool.name : toolId;
'merge': 'Merge PDF',
'compress': 'Compress PDF',
'convert': 'Convert PDF',
'addPassword': 'Add Password',
'changePermissions': 'Change Permissions',
'sanitize': 'Sanitize PDF',
'ocr': 'OCR PDF'
};
return displayNames[toolKey] || toolKey;
} }
// Note: isValidMode is now imported from types/navigation.ts
/** /**
* Generate shareable URL for current tool state * Generate shareable URL for current tool state using registry
* Only generates URLs for actual tools, not internal UI modes
*/ */
export function generateShareableUrl(mode: ModeType, toolKey?: string): string { export function generateShareableUrl(toolId: ToolId | null, registry: ToolRegistry): string {
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
// Don't generate URLs for internal UI modes if (!toolId || !registry[toolId]) {
if (mode === 'viewer' || mode === 'fileEditor' || mode === 'pageEditor') {
return baseUrl; return baseUrl;
} }
if (toolKey) { const tool = registry[toolId];
const pathMappings: Record<string, string> = {
'split': '/split-pdf',
'merge': '/merge-pdf',
'compress': '/compress-pdf',
'convert': '/convert-pdf',
'addPassword': '/add-password-pdf',
'changePermissions': '/change-permissions-pdf',
'sanitize': '/sanitize-pdf',
'ocr': '/ocr-pdf',
'addWatermark': '/watermark',
'removePassword': '/remove-password',
'single-large-page': '/single-large-page',
'repair': '/repair',
'unlockPdfForms': '/unlock-pdf-forms',
'removeCertificateSign': '/remove-certificate-sign'
};
const path = pathMappings[toolKey] || `/${toolKey}`; const path = getToolUrlPath(toolId, tool);
return `${baseUrl}${path}`; return `${baseUrl}${path}`;
}
return baseUrl;
} }