diff --git a/build.gradle b/build.gradle
index 1738c1ef72..2ae1355944 100644
--- a/build.gradle
+++ b/build.gradle
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
- version = '2.0.3'
+ version = '2.1.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index dd30ca4499..2d2fa19c6e 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -5991,6 +5991,7 @@ earlyAccess = "Early Access"
reset = "Reset Changes"
downloadJson = "Download JSON"
generatePdf = "Generate PDF"
+saveChanges = "Save Changes"
[pdfTextEditor.options.autoScaleText]
title = "Auto-scale text to fit boxes"
@@ -6028,6 +6029,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
[pdfTextEditor.empty]
title = "No document loaded"
subtitle = "Load a PDF or JSON file to begin editing text content."
+dropzone = "Drag and drop a PDF or JSON file here, or click to browse"
+dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse"
[pdfTextEditor.welcomeBanner]
title = "Welcome to PDF Text Editor (Early Access)"
diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx
index f6477c67aa..0fa31dd244 100644
--- a/frontend/src/core/components/layout/Workbench.tsx
+++ b/frontend/src/core/components/layout/Workbench.tsx
@@ -71,6 +71,20 @@ export default function Workbench() {
};
const renderMainContent = () => {
+ // Check for custom workbench views first
+ if (!isBaseWorkbench(currentView)) {
+ const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
+ if (customView) {
+ // PDF text editor handles its own empty state (shows dropzone when no document)
+ const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
+ if (handlesOwnEmptyState || activeFiles.length > 0) {
+ const CustomComponent = customView.component;
+ return ;
+ }
+ }
+ }
+
+ // For base workbenches (or custom views that don't handle empty state), show landing page when no files
if (activeFiles.length === 0) {
return (
view.workbenchId === currentView && view.data != null);
-
-
- if (customView) {
- const CustomComponent = customView.component;
- return ;
- }
- }
return ;
}
};
diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx
index cc7a8777c1..efa9a2a5d3 100644
--- a/frontend/src/core/components/shared/AllToolsNavButton.tsx
+++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
+import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
@@ -20,21 +21,36 @@ const AllToolsNavButton: React.FC = ({
}) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
+ const { hasUnsavedChanges } = useNavigationState();
+ const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
- const handleClick = () => {
+ const performNavigation = () => {
setActiveButton('tools');
// Preserve existing behavior used in QuickAccessBar header
handleReaderToggle();
handleBackToTools();
};
+ const handleClick = () => {
+ if (hasUnsavedChanges) {
+ navigationActions.requestNavigation(performNavigation);
+ return;
+ }
+ performNavigation();
+ };
+
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
+ if (hasUnsavedChanges) {
+ e.preventDefault();
+ navigationActions.requestNavigation(performNavigation);
+ return;
+ }
handleUnlessSpecialClick(e, handleClick);
};
diff --git a/frontend/src/core/components/shared/NavigationWarningModal.tsx b/frontend/src/core/components/shared/NavigationWarningModal.tsx
index faff074279..b8803f1760 100644
--- a/frontend/src/core/components/shared/NavigationWarningModal.tsx
+++ b/frontend/src/core/components/shared/NavigationWarningModal.tsx
@@ -12,7 +12,7 @@ interface NavigationWarningModalProps {
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
- const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
+ const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const handleKeepWorking = () => {
@@ -41,7 +41,9 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
};
const BUTTON_WIDTH = "10rem";
- if (!hasUnsavedChanges) {
+ // Only show modal if there are unsaved changes AND there's an actual pending navigation
+ // This prevents the modal from showing due to spurious state updates
+ if (!hasUnsavedChanges || !pendingNavigation) {
return null;
}
diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx
index 029a6d567e..28efd60cbc 100644
--- a/frontend/src/core/components/shared/QuickAccessBar.tsx
+++ b/frontend/src/core/components/shared/QuickAccessBar.tsx
@@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
+import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
@@ -32,6 +33,8 @@ const QuickAccessBar = forwardRef((_, ref) => {
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
+ const { hasUnsavedChanges } = useNavigationState();
+ const { actions: navigationActions } = useNavigationActions();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const licenseAlert = useLicenseAlert();
@@ -58,7 +61,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
};
// Helper function to render navigation buttons with URL support
- const renderNavButton = (config: ButtonConfig, index: number) => {
+ const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
@@ -67,6 +70,14 @@ const QuickAccessBar = forwardRef((_, ref) => {
: null;
const handleClick = (e?: React.MouseEvent) => {
+ // If there are unsaved changes and this button should guard navigation, show warning modal
+ if (shouldGuardNavigation && hasUnsavedChanges) {
+ e?.preventDefault();
+ navigationActions.requestNavigation(() => {
+ config.onClick();
+ });
+ return;
+ }
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
@@ -89,7 +100,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
- onClick: () => handleClick(),
+ onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
})}
size={isActive ? 'lg' : 'md'}
@@ -222,7 +233,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
{mainButtons.map((config, index) => (
- {renderNavButton(config, index)}
+ {renderNavButton(config, index, config.id === 'read' || config.id === 'automate')}
))}
diff --git a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx
index 273c09d0e1..55b79b26ca 100644
--- a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx
+++ b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx
@@ -16,6 +16,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
+import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import FitText from '@app/components/shared/FitText';
@@ -31,6 +32,8 @@ const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC = ({ setActiveButton, tooltipPosition = 'right' }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
+ const { hasUnsavedChanges } = useNavigationState();
+ const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
@@ -150,10 +153,16 @@ const ActiveToolButton: React.FC = ({ setActiveButton, to
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
- handleUnlessSpecialClick(e, () => {
+ const performNavigation = () => {
setActiveButton('tools');
handleBackToTools();
- });
+ };
+ if (hasUnsavedChanges) {
+ e.preventDefault();
+ navigationActions.requestNavigation(performNavigation);
+ return;
+ }
+ handleUnlessSpecialClick(e, performNavigation);
}}
size={'lg'}
variant="subtle"
diff --git a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx
index 0dbe8c50e1..4eeff845b3 100644
--- a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx
+++ b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx
@@ -21,6 +21,7 @@ import {
Title,
Tooltip,
} from '@mantine/core';
+import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined';
@@ -32,9 +33,12 @@ import CloseIcon from '@mui/icons-material/Close';
import MergeTypeIcon from '@mui/icons-material/MergeType';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
+import UploadFileIcon from '@mui/icons-material/UploadFileOutlined';
+import SaveIcon from '@mui/icons-material/SaveOutlined';
import { Rnd } from 'react-rnd';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
+import { useFileContext } from '@app/contexts/FileContext';
import {
PdfTextEditorViewData,
PdfJsonFont,
@@ -313,6 +317,7 @@ type GroupingMode = 'auto' | 'paragraph' | 'singleLine';
const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const { t } = useTranslation();
+ const { activeFiles } = useFileContext();
const [activeGroupId, setActiveGroupId] = useState(null);
const [editingGroupId, setEditingGroupId] = useState(null);
const [activeImageId, setActiveImageId] = useState(null);
@@ -375,6 +380,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
fileName,
errorMessage,
isGeneratingPdf,
+ isSavingToWorkbench,
isConverting,
conversionProgress,
hasChanges,
@@ -389,11 +395,12 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
onReset,
onDownloadJson,
onGeneratePdf,
- onGeneratePdfForNavigation,
+ onSaveToWorkbench,
onForceSingleTextElementChange,
onGroupingModeChange,
onMergeGroups,
onUngroupGroup,
+ onLoadFile,
} = data;
// Define derived variables immediately after props destructuring, before any hooks
@@ -1430,7 +1437,8 @@ const selectionToolbarPosition = useMemo(() => {
height: '100%',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
- alignItems: 'start',
+ gridTemplateRows: '1fr',
+ alignItems: hasDocument ? 'start' : 'stretch',
gap: '1.5rem',
}}
>
@@ -1486,6 +1494,17 @@ const selectionToolbarPosition = useMemo(() => {
>
{t('pdfTextEditor.actions.generatePdf', 'Generate PDF')}
+ }
+ onClick={onSaveToWorkbench}
+ loading={isSavingToWorkbench}
+ disabled={!hasDocument || !hasChanges || isConverting}
+ fullWidth
+ >
+ {t('pdfTextEditor.actions.saveChanges', 'Save Changes')}
+
{fileName && (
@@ -1639,17 +1658,45 @@ const selectionToolbarPosition = useMemo(() => {
)}
{!hasDocument && !isConverting && (
-
-
-
-
- {t('pdfTextEditor.empty.title', 'No document loaded')}
-
-
- {t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')}
-
-
-
+
+ {
+ if (files.length > 0) {
+ onLoadFile(files[0]);
+ }
+ }}
+ accept={['application/pdf', 'application/json']}
+ maxFiles={1}
+ style={{
+ width: '100%',
+ maxWidth: 480,
+ minHeight: 200,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ border: '2px dashed var(--mantine-color-gray-4)',
+ borderRadius: 'var(--mantine-radius-lg)',
+ cursor: 'pointer',
+ transition: 'border-color 150ms ease, background-color 150ms ease',
+ }}
+ >
+
+
+
+ {t('pdfTextEditor.empty.title', 'No document loaded')}
+
+
+ {activeFiles.length > 0
+ ? t('pdfTextEditor.empty.dropzoneWithFiles', 'Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse')
+ : t('pdfTextEditor.empty.dropzone', 'Drag and drop a PDF or JSON file here, or click to browse')}
+
+
+
+
)}
{isConverting && (
@@ -1683,7 +1730,7 @@ const selectionToolbarPosition = useMemo(() => {
)}
- {hasDocument && (
+ {hasDocument && !isConverting && (
{
{/* Navigation Warning Modal */}
);
diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx
index 500a6db5e1..c11649400a 100644
--- a/frontend/src/core/contexts/NavigationContext.tsx
+++ b/frontend/src/core/contexts/NavigationContext.tsx
@@ -1,4 +1,4 @@
-import React, { createContext, useContext, useReducer, useCallback } from 'react';
+import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
import { WorkbenchType, getDefaultWorkbench } from '@app/types/workbench';
import { ToolId, isValidToolId } from '@app/types/toolId';
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
@@ -110,8 +110,8 @@ export const NavigationProvider: React.FC<{
const { allTools: toolRegistry } = useToolRegistry();
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
- const actions: NavigationContextActions = {
- setWorkbench: useCallback((workbench: WorkbenchType) => {
+ // Memoize individual callbacks
+ const setWorkbench = useCallback((workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
console.log('[NavigationContext] setWorkbench:', {
@@ -152,13 +152,13 @@ export const NavigationProvider: React.FC<{
} else {
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
}
- }, [state.workbench, state.hasUnsavedChanges]),
+ }, [state.workbench, state.hasUnsavedChanges]);
- setSelectedTool: useCallback((toolId: ToolId | null) => {
+ const setSelectedTool = useCallback((toolId: ToolId | null) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
- }, []),
+ }, []);
- setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
+ const setToolAndWorkbench = useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
@@ -177,25 +177,25 @@ export const NavigationProvider: React.FC<{
} else {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
}
- }, [state.workbench, state.hasUnsavedChanges]),
+ }, [state.workbench, state.hasUnsavedChanges]);
- setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
+ const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
- }, []),
+ }, []);
- registerUnsavedChangesChecker: useCallback((checker: () => boolean) => {
+ const registerUnsavedChangesChecker = useCallback((checker: () => boolean) => {
unsavedChangesCheckerRef.current = checker;
- }, []),
+ }, []);
- unregisterUnsavedChangesChecker: useCallback(() => {
+ const unregisterUnsavedChangesChecker = useCallback(() => {
unsavedChangesCheckerRef.current = null;
- }, []),
+ }, []);
- showNavigationWarning: useCallback((show: boolean) => {
+ const showNavigationWarning = useCallback((show: boolean) => {
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
- }, []),
+ }, []);
- requestNavigation: useCallback((navigationFn: () => void) => {
+ const requestNavigation = useCallback((navigationFn: () => void) => {
if (!state.hasUnsavedChanges) {
navigationFn();
return;
@@ -203,9 +203,9 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
- }, [state.hasUnsavedChanges]),
+ }, [state.hasUnsavedChanges]);
- confirmNavigation: useCallback(() => {
+ const confirmNavigation = useCallback(() => {
console.log('[NavigationContext] confirmNavigation called', {
hasPendingNav: !!state.pendingNavigation,
currentWorkbench: state.workbench,
@@ -218,18 +218,18 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
console.log('[NavigationContext] confirmNavigation completed');
- }, [state.pendingNavigation, state.workbench, state.selectedTool]),
+ }, [state.pendingNavigation, state.workbench, state.selectedTool]);
- cancelNavigation: useCallback(() => {
+ const cancelNavigation = useCallback(() => {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
- }, []),
+ }, []);
- clearToolSelection: useCallback(() => {
+ const clearToolSelection = useCallback(() => {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
- }, []),
+ }, []);
- handleToolSelect: useCallback((toolId: string) => {
+ const handleToolSelect = useCallback((toolId: string) => {
if (toolId === 'allTools') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
return;
@@ -245,11 +245,40 @@ export const NavigationProvider: React.FC<{
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
- // Validate toolId and convert to ToolId type
- const validToolId = isValidToolId(toolId) ? toolId : null;
- dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
- }, [toolRegistry])
- };
+ // Validate toolId and convert to ToolId type
+ const validToolId = isValidToolId(toolId) ? toolId : null;
+ dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
+ }, [toolRegistry]);
+
+ // Memoize the actions object to prevent unnecessary context updates
+ // This is critical to avoid infinite loops when effects depend on actions
+ const actions: NavigationContextActions = useMemo(() => ({
+ setWorkbench,
+ setSelectedTool,
+ setToolAndWorkbench,
+ setHasUnsavedChanges,
+ registerUnsavedChangesChecker,
+ unregisterUnsavedChangesChecker,
+ showNavigationWarning,
+ requestNavigation,
+ confirmNavigation,
+ cancelNavigation,
+ clearToolSelection,
+ handleToolSelect,
+ }), [
+ setWorkbench,
+ setSelectedTool,
+ setToolAndWorkbench,
+ setHasUnsavedChanges,
+ registerUnsavedChangesChecker,
+ unregisterUnsavedChangesChecker,
+ showNavigationWarning,
+ requestNavigation,
+ confirmNavigation,
+ cancelNavigation,
+ clearToolSelection,
+ handleToolSelect,
+ ]);
const stateValue: NavigationContextStateValue = {
workbench: state.workbench,
@@ -259,9 +288,10 @@ export const NavigationProvider: React.FC<{
showNavigationWarning: state.showNavigationWarning
};
- const actionsValue: NavigationContextActionsValue = {
+ // Also memoize the context value to prevent unnecessary re-renders
+ const actionsValue: NavigationContextActionsValue = useMemo(() => ({
actions
- };
+ }), [actions]);
return (
diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx
index 9717b7f68f..b2c6958018 100644
--- a/frontend/src/core/contexts/ToolWorkflowContext.tsx
+++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx
@@ -224,11 +224,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
return;
}
+ if (navigationState.pendingNavigation || navigationState.showNavigationWarning) {
+ return;
+ }
+
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
if (!currentCustomView || currentCustomView.data == null) {
actions.setWorkbench(getDefaultWorkbench());
}
- }, [actions, customWorkbenchViews, navigationState.workbench]);
+ }, [actions, customWorkbenchViews, navigationState.workbench, navigationState.pendingNavigation, navigationState.showNavigationWarning]);
// Persisted via PreferencesContext; no direct localStorage writes needed here
diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx
index bc514d879a..0844b94d1a 100644
--- a/frontend/src/core/data/useTranslatedToolRegistry.tsx
+++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx
@@ -152,6 +152,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
// Proprietary tools (if any)
...proprietaryTools,
// Recommended Tools in order
+ pdfTextEditor: {
+ icon: ,
+ name: t("home.pdfTextEditor.title", "PDF Text Editor"),
+ component: PdfTextEditor,
+ description: t(
+ "home.pdfTextEditor.desc",
+ "Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
+ ),
+ categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
+ subcategoryId: SubcategoryId.GENERAL,
+ maxFiles: 1,
+ endpoints: ["text-editor-pdf"],
+ synonyms: getSynonyms(t, "pdfTextEditor"),
+ supportsAutomate: false,
+ automationSettings: null,
+ versionStatus: "alpha",
+ },
multiTool: {
icon: ,
name: t("home.multiTool.title", "Multi-Tool"),
@@ -893,23 +910,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: RedactSingleStepSettings,
synonyms: getSynonyms(t, "redact")
},
- pdfTextEditor: {
- icon: ,
- name: t("home.pdfTextEditor.title", "PDF Text Editor"),
- component: PdfTextEditor,
- description: t(
- "home.pdfTextEditor.desc",
- "Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
- ),
- categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
- subcategoryId: SubcategoryId.GENERAL,
- maxFiles: 1,
- endpoints: ["text-editor-pdf"],
- synonyms: getSynonyms(t, "pdfTextEditor"),
- supportsAutomate: false,
- automationSettings: null,
- versionStatus: "alpha",
- },
};
const regularTools = {} as RegularToolRegistry;
diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx
index b629e86a5c..c22a9624d9 100644
--- a/frontend/src/core/pages/HomePage.tsx
+++ b/frontend/src/core/pages/HomePage.tsx
@@ -59,17 +59,21 @@ export default function HomePage() {
const prevFileCountRef = useRef(activeFiles.length);
// Auto-switch to viewer when going from 0 to 1 file
+ // Skip this if PDF Text Editor is active - it handles its own empty state
useEffect(() => {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
if (prevCount === 0 && currentCount === 1) {
- actions.setWorkbench('viewer');
- setActiveFileIndex(0);
+ // PDF Text Editor handles its own empty state with a dropzone
+ if (selectedToolKey !== 'pdfTextEditor') {
+ actions.setWorkbench('viewer');
+ setActiveFileIndex(0);
+ }
}
prevFileCountRef.current = currentCount;
- }, [activeFiles.length, actions, setActiveFileIndex]);
+ }, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath();
diff --git a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
index 571f3be09a..533dc644b3 100644
--- a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
+++ b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx
@@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
-import { useFileSelection } from '@app/contexts/FileContext';
+import { useFileSelection, useFileManagement, useFileContext } from '@app/contexts/FileContext';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
+import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
+import { getDefaultWorkbench } from '@app/types/workbench';
import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants';
import apiClient from '@app/services/apiClient';
import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils';
@@ -208,7 +210,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
} = useToolWorkflow();
const { actions: navigationActions } = useNavigationActions();
const navigationState = useNavigationState();
- const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = navigationActions;
+ const { addFiles } = useFileManagement();
+ const { consumeFiles, selectors } = useFileContext();
const [loadedDocument, setLoadedDocument] = useState(null);
const [groupsByPage, setGroupsByPage] = useState([]);
@@ -217,6 +220,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const [fileName, setFileName] = useState('');
const [errorMessage, setErrorMessage] = useState(null);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
+ const [isSavingToWorkbench, setIsSavingToWorkbench] = useState(false);
+ const [shouldNavigateAfterSave, setShouldNavigateAfterSave] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [conversionProgress, setConversionProgress] = useState(null);
const [forceSingleTextElement, setForceSingleTextElement] = useState(true);
@@ -234,6 +239,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const originalGroupsRef = useRef([]);
const imagesByPageRef = useRef([]);
const autoLoadKeyRef = useRef(null);
+ const sourceFileIdRef = useRef(null);
const loadRequestIdRef = useRef(0);
const latestPdfRequestIdRef = useRef(null);
const loadedDocumentRef = useRef(null);
@@ -279,6 +285,23 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
);
const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]);
const hasDocument = loadedDocument !== null;
+
+ // Sync hasChanges to navigation context so navigation guards can block
+ useEffect(() => {
+ navigationActions.setHasUnsavedChanges(hasChanges);
+ return () => {
+ navigationActions.setHasUnsavedChanges(false);
+ };
+ }, [hasChanges, navigationActions]);
+
+ // Navigate to files view AFTER the unsaved changes state is properly cleared
+ useEffect(() => {
+ if (shouldNavigateAfterSave && !navigationState.hasUnsavedChanges) {
+ setShouldNavigateAfterSave(false);
+ navigationActions.setToolAndWorkbench(null, getDefaultWorkbench());
+ }
+ }, [shouldNavigateAfterSave, navigationState.hasUnsavedChanges, navigationActions]);
+
const viewLabel = useMemo(() => t('pdfTextEditor.viewLabel', 'PDF Editor'), [t]);
const { selectedFiles } = useFileSelection();
@@ -720,6 +743,21 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
[groupingMode, resetToDocument, t],
);
+ // Wrapper for loading files from the dropzone - adds to workbench first
+ const handleLoadFileFromDropzone = useCallback(
+ async (file: File) => {
+ // Add the file to the workbench so it appears in the file list
+ const addedFiles = await addFiles([file]);
+ // Capture the file ID for save-to-workbench functionality
+ if (addedFiles.length > 0 && addedFiles[0].fileId) {
+ sourceFileIdRef.current = addedFiles[0].fileId;
+ }
+ // Then load it into the editor
+ void handleLoadFile(file);
+ },
+ [addFiles, handleLoadFile],
+ );
+
const handleSelectPage = useCallback((pageIndex: number) => {
setSelectedPage(pageIndex);
// Trigger lazy loading for images on the selected page
@@ -1122,6 +1160,229 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
t,
]);
+ // Save changes to workbench (replaces the original file with edited version)
+ const handleSaveToWorkbench = useCallback(async () => {
+ setIsSavingToWorkbench(true);
+
+ try {
+ if (!sourceFileIdRef.current) {
+ console.warn('[PdfTextEditor] No source file ID available for save to workbench');
+ // Fall back to generating PDF download if no source file
+ await handleGeneratePdf(true);
+ return;
+ }
+
+ const parentStub = selectors.getStirlingFileStub(sourceFileIdRef.current as any);
+ if (!parentStub) {
+ console.warn('[PdfTextEditor] Could not find parent stub for save to workbench');
+ await handleGeneratePdf(true);
+ return;
+ }
+
+ const ensureImagesForPages = async (pageIndices: number[]) => {
+ const uniqueIndices = Array.from(new Set(pageIndices)).filter((index) => index >= 0);
+ if (uniqueIndices.length === 0) {
+ return;
+ }
+
+ for (const index of uniqueIndices) {
+ if (!loadedImagePagesRef.current.has(index)) {
+ await loadImagesForPage(index);
+ }
+ }
+
+ const maxWaitTime = 15000;
+ const pollInterval = 150;
+ const startWait = Date.now();
+ while (Date.now() - startWait < maxWaitTime) {
+ const allLoaded = uniqueIndices.every(
+ (index) =>
+ loadedImagePagesRef.current.has(index) &&
+ imagesByPageRef.current[index] !== undefined,
+ );
+ const anyLoading = uniqueIndices.some((index) =>
+ loadingImagePagesRef.current.has(index),
+ );
+ if (allLoaded && !anyLoading) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ }
+
+ const missing = uniqueIndices.filter(
+ (index) => !loadedImagePagesRef.current.has(index),
+ );
+ if (missing.length > 0) {
+ throw new Error(
+ `Failed to load images for pages ${missing.map((i) => i + 1).join(', ')}`,
+ );
+ }
+ };
+
+ const currentDoc = loadedDocumentRef.current;
+ const totalPages = currentDoc?.pages?.length ?? 0;
+ const currentDirtyPages = getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current);
+ const dirtyPageIndices = currentDirtyPages
+ .map((isDirty, index) => (isDirty ? index : -1))
+ .filter((index) => index >= 0);
+
+ let pdfBlob: Blob;
+ let downloadName: string;
+
+ const canUseIncremental =
+ isLazyMode &&
+ cachedJobId &&
+ dirtyPageIndices.length > 0 &&
+ dirtyPageIndices.length < totalPages;
+
+ if (canUseIncremental) {
+ await ensureImagesForPages(dirtyPageIndices);
+
+ try {
+ const payload = buildPayload();
+ if (!payload) {
+ throw new Error('Failed to build payload');
+ }
+
+ const { document, filename } = payload;
+ const dirtyPageSet = new Set(dirtyPageIndices);
+ const partialPages =
+ document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
+
+ const partialDocument: PdfJsonDocument = {
+ metadata: document.metadata,
+ xmpMetadata: document.xmpMetadata,
+ fonts: document.fonts,
+ lazyImages: true,
+ pages: partialPages,
+ };
+
+ const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
+ const expectedName = `${baseName || 'document'}.pdf`;
+ const response = await apiClient.post(
+ `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`,
+ partialDocument,
+ {
+ responseType: 'blob',
+ },
+ );
+
+ const contentDisposition = response.headers?.['content-disposition'] ?? '';
+ const detectedName = getFilenameFromHeaders(contentDisposition);
+ downloadName = detectedName || expectedName;
+ pdfBlob = response.data;
+ } catch (incrementalError) {
+ console.warn(
+ '[handleSaveToWorkbench] Incremental export failed, falling back to full export',
+ incrementalError,
+ );
+ // Fall through to full export
+ if (isLazyMode && totalPages > 0) {
+ const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
+ await ensureImagesForPages(allPageIndices);
+ }
+
+ const payload = buildPayload();
+ if (!payload) {
+ throw new Error('Failed to build payload');
+ }
+
+ const { document, filename } = payload;
+ const serialized = JSON.stringify(document);
+ const jsonFile = new File([serialized], filename, { type: 'application/json' });
+
+ const formData = new FormData();
+ formData.append('fileInput', jsonFile);
+ const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, {
+ responseType: 'blob',
+ });
+
+ const contentDisposition = response.headers?.['content-disposition'] ?? '';
+ const detectedName = getFilenameFromHeaders(contentDisposition);
+ const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
+ downloadName = detectedName || `${baseName || 'document'}.pdf`;
+ pdfBlob = response.data;
+ }
+ } else {
+ if (isLazyMode && totalPages > 0) {
+ const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
+ await ensureImagesForPages(allPageIndices);
+ }
+
+ const payload = buildPayload();
+ if (!payload) {
+ throw new Error('Failed to build payload');
+ }
+
+ const { document, filename } = payload;
+ const serialized = JSON.stringify(document);
+ const jsonFile = new File([serialized], filename, { type: 'application/json' });
+
+ const formData = new FormData();
+ formData.append('fileInput', jsonFile);
+ const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, {
+ responseType: 'blob',
+ });
+
+ const contentDisposition = response.headers?.['content-disposition'] ?? '';
+ const detectedName = getFilenameFromHeaders(contentDisposition);
+ const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
+ downloadName = detectedName || `${baseName || 'document'}.pdf`;
+ pdfBlob = response.data;
+ }
+
+ // Create the new PDF file
+ const pdfFile = new File([pdfBlob], downloadName, { type: 'application/pdf' });
+
+ // Create StirlingFile and stub for the output
+ const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(
+ [pdfFile],
+ parentStub,
+ 'pdfTextEditor',
+ );
+
+ // Replace the original file with the edited version
+ await consumeFiles([sourceFileIdRef.current as any], stirlingFiles, stubs);
+
+ // Update the source file ID to point to the new file
+ sourceFileIdRef.current = stubs[0].id;
+
+ // Clear the unsaved changes flag - this will trigger the useEffect to navigate
+ // once React has processed the state update
+ navigationActions.setHasUnsavedChanges(false);
+ setErrorMessage(null);
+
+ // Set flag to trigger navigation after state update is processed
+ setShouldNavigateAfterSave(true);
+ } catch (error: any) {
+ console.error('Failed to save to workbench', error);
+ const message =
+ error?.response?.data ||
+ error?.message ||
+ t('pdfTextEditor.errors.pdfConversion', 'Unable to save changes to workbench.');
+ const msgString = typeof message === 'string' ? message : String(message);
+ setErrorMessage(msgString);
+ if (onError) {
+ onError(msgString);
+ }
+ } finally {
+ setIsSavingToWorkbench(false);
+ }
+ }, [
+ buildPayload,
+ cachedJobId,
+ consumeFiles,
+ groupsByPage,
+ handleGeneratePdf,
+ imagesByPage,
+ isLazyMode,
+ loadImagesForPage,
+ navigationActions,
+ onError,
+ selectors,
+ t,
+ ]);
+
const requestPagePreview = useCallback(
async (pageIndex: number, scale: number) => {
if (!hasVectorPreview || !pdfDocumentRef.current) {
@@ -1260,6 +1521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
fileName,
errorMessage,
isGeneratingPdf,
+ isSavingToWorkbench,
isConverting,
conversionProgress,
hasChanges,
@@ -1278,15 +1540,19 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
// Generate PDF without triggering tool completion
await handleGeneratePdf(true);
},
+ onSaveToWorkbench: handleSaveToWorkbench,
onForceSingleTextElementChange: setForceSingleTextElement,
onGroupingModeChange: setGroupingMode,
onMergeGroups: handleMergeGroups,
onUngroupGroup: handleUngroupGroup,
+ onLoadFile: handleLoadFileFromDropzone,
}), [
handleMergeGroups,
handleUngroupGroup,
handleImageTransform,
+ handleSaveToWorkbench,
imagesByPage,
+ isSavingToWorkbench,
pagePreviews,
dirtyPages,
errorMessage,
@@ -1311,6 +1577,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
groupingMode,
requestPagePreview,
setForceSingleTextElement,
+ handleLoadFileFromDropzone,
]);
const latestViewDataRef = useRef(viewData);
@@ -1326,6 +1593,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
useEffect(() => {
if (selectedFiles.length === 0) {
autoLoadKeyRef.current = null;
+ sourceFileIdRef.current = null;
return;
}
@@ -1344,6 +1612,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
}
autoLoadKeyRef.current = fileKey;
+ // Capture the source file ID for save-to-workbench functionality
+ sourceFileIdRef.current = (file as any).fileId ?? null;
void handleLoadFile(file);
}, [selectedFiles, navigationState.selectedTool, handleLoadFile]);
@@ -1398,27 +1668,6 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
// The workbench should be set when the tool is selected via proper channels
// (tool registry, tool picker, etc.) - not forced here
- // Keep hasChanges in a ref for the checker to access
- const hasChangesRef = useRef(hasChanges);
- useEffect(() => {
- hasChangesRef.current = hasChanges;
- console.log('[PdfTextEditor] hasChanges updated to:', hasChanges);
- }, [hasChanges]);
-
- // Register unsaved changes checker for navigation guard
- useEffect(() => {
- const checker = () => {
- console.log('[PdfTextEditor] Checking unsaved changes:', hasChangesRef.current);
- return hasChangesRef.current;
- };
- registerUnsavedChangesChecker(checker);
- console.log('[PdfTextEditor] Registered unsaved changes checker');
- return () => {
- console.log('[PdfTextEditor] Unregistered unsaved changes checker');
- unregisterUnsavedChangesChecker();
- };
- }, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
-
const lastSentViewDataRef = useRef(null);
useEffect(() => {
diff --git a/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts b/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts
index 8439bb4c18..3dd45a4657 100644
--- a/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts
+++ b/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts
@@ -221,8 +221,11 @@ export interface PdfTextEditorViewData {
onDownloadJson: () => void;
onGeneratePdf: () => void;
onGeneratePdfForNavigation: () => Promise;
+ onSaveToWorkbench: () => Promise;
+ isSavingToWorkbench: boolean;
onForceSingleTextElementChange: (value: boolean) => void;
onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void;
onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean;
onUngroupGroup: (pageIndex: number, groupId: string) => boolean;
+ onLoadFile: (file: File) => void;
}