couple of small fixes for text editor (#5155)

# Description of Changes

- Workbench.tsx: Allow PDF text editor to handle it's own custom state
(this is a bit of a hotfix, we're going to handle the PDF text editors
file upload flow better in a future PR).
- PdfTextEditorView.tsx: Added a dropzone and some helper text to upload
the first file.
- PdfTextEditorView.tsx: Hide document view when isConverting is true.
Prevents showing stale content from previous file during conversion.
- useTranslatedToolRegistry.tsx: Moved PDF Text Editor to top of
Recommended tools list. Increased visibility of the new feature.
- PdfTextEditor.tsx: Removed auto-navigation to PDF Editor workbench on
file selection. Stops the "jumpy" behavior when selecting files.
- HomePage.tsx: Check specifically for pdfTextEditor tool instead of any
custom workbench. Prevents auto-switch to viewer when uploading files
while in PDF Text Editor.

<img width="2056" height="1073" alt="Screenshot 2025-12-03 at 6 01
14 PM"
src="https://github.com/user-attachments/assets/dfc63a46-7991-486c-ba00-0ce7637502f5"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
EthanHealy01
2025-12-03 20:37:23 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent f8dbf171e1
commit c9bf436895
14 changed files with 492 additions and 109 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects { allprojects {
group = 'stirling.software' group = 'stirling.software'
version = '2.0.3' version = '2.1.0'
configurations.configureEach { configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging' exclude group: 'commons-logging', module: 'commons-logging'
@@ -5991,6 +5991,7 @@ earlyAccess = "Early Access"
reset = "Reset Changes" reset = "Reset Changes"
downloadJson = "Download JSON" downloadJson = "Download JSON"
generatePdf = "Generate PDF" generatePdf = "Generate PDF"
saveChanges = "Save Changes"
[pdfTextEditor.options.autoScaleText] [pdfTextEditor.options.autoScaleText]
title = "Auto-scale text to fit boxes" title = "Auto-scale text to fit boxes"
@@ -6028,6 +6029,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
[pdfTextEditor.empty] [pdfTextEditor.empty]
title = "No document loaded" title = "No document loaded"
subtitle = "Load a PDF or JSON file to begin editing text content." 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] [pdfTextEditor.welcomeBanner]
title = "Welcome to PDF Text Editor (Early Access)" title = "Welcome to PDF Text Editor (Early Access)"
@@ -71,6 +71,20 @@ export default function Workbench() {
}; };
const renderMainContent = () => { 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 <CustomComponent data={customView.data} />;
}
}
}
// For base workbenches (or custom views that don't handle empty state), show landing page when no files
if (activeFiles.length === 0) { if (activeFiles.length === 0) {
return ( return (
<LandingPage <LandingPage
@@ -143,15 +157,6 @@ export default function Workbench() {
); );
default: default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
return <LandingPage />; return <LandingPage />;
} }
}; };
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip'; import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded'; import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
@@ -20,21 +21,36 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation(); const { getHomeNavigation } = useSidebarNavigation();
const handleClick = () => { const performNavigation = () => {
setActiveButton('tools'); setActiveButton('tools');
// Preserve existing behavior used in QuickAccessBar header // Preserve existing behavior used in QuickAccessBar header
handleReaderToggle(); handleReaderToggle();
handleBackToTools(); handleBackToTools();
}; };
const handleClick = () => {
if (hasUnsavedChanges) {
navigationActions.requestNavigation(performNavigation);
return;
}
performNavigation();
};
// Do not highlight All Tools when a specific tool is open (indicator is shown) // Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker'; const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation(); const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => { const handleNavClick = (e: React.MouseEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
navigationActions.requestNavigation(performNavigation);
return;
}
handleUnlessSpecialClick(e, handleClick); handleUnlessSpecialClick(e, handleClick);
}; };
@@ -12,7 +12,7 @@ interface NavigationWarningModalProps {
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => { const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } = const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard(); useNavigationGuard();
const handleKeepWorking = () => { const handleKeepWorking = () => {
@@ -41,7 +41,9 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
}; };
const BUTTON_WIDTH = "10rem"; 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; return null;
} }
@@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
import { useIsOverflowing } from '@app/hooks/useIsOverflowing'; import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar'; import { ButtonConfig } from '@app/types/sidebar';
@@ -32,6 +33,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getToolNavigation } = useSidebarNavigation(); const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig(); const { config } = useAppConfig();
const licenseAlert = useLicenseAlert(); const licenseAlert = useLicenseAlert();
@@ -58,7 +61,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}; };
// Helper function to render navigation buttons with URL support // 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); const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support // Check if this button has URL navigation support
@@ -67,6 +70,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
: null; : null;
const handleClick = (e?: React.MouseEvent) => { 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) { if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick); handleUnlessSpecialClick(e, config.onClick);
} else { } else {
@@ -89,7 +100,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e), onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name 'aria-label': config.name
} : { } : {
onClick: () => handleClick(), onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name 'aria-label': config.name
})} })}
size={isActive ? 'lg' : 'md'} size={isActive ? 'lg' : 'md'}
@@ -222,7 +233,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<Stack gap="lg" align="center"> <Stack gap="lg" align="center">
{mainButtons.map((config, index) => ( {mainButtons.map((config, index) => (
<React.Fragment key={config.id}> <React.Fragment key={config.id}>
{renderNavButton(config, index)} {renderNavButton(config, index, config.id === 'read' || config.id === 'automate')}
</React.Fragment> </React.Fragment>
))} ))}
</Stack> </Stack>
@@ -16,6 +16,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core'; import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import FitText from '@app/components/shared/FitText'; import FitText from '@app/components/shared/FitText';
@@ -31,6 +32,8 @@ const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = 'right' }) => { const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = 'right' }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation(); const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet) // Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
@@ -150,10 +153,16 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
component="a" component="a"
href={getHomeNavigation().href} href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => { const performNavigation = () => {
setActiveButton('tools'); setActiveButton('tools');
handleBackToTools(); handleBackToTools();
}); };
if (hasUnsavedChanges) {
e.preventDefault();
navigationActions.requestNavigation(performNavigation);
return;
}
handleUnlessSpecialClick(e, performNavigation);
}} }}
size={'lg'} size={'lg'}
variant="subtle" variant="subtle"
@@ -21,6 +21,7 @@ import {
Title, Title,
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined'; import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined'; 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 MergeTypeIcon from '@mui/icons-material/MergeType';
import CallSplitIcon from '@mui/icons-material/CallSplit'; import CallSplitIcon from '@mui/icons-material/CallSplit';
import MoreVertIcon from '@mui/icons-material/MoreVert'; 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 { Rnd } from 'react-rnd';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { useFileContext } from '@app/contexts/FileContext';
import { import {
PdfTextEditorViewData, PdfTextEditorViewData,
PdfJsonFont, PdfJsonFont,
@@ -313,6 +317,7 @@ type GroupingMode = 'auto' | 'paragraph' | 'singleLine';
const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeFiles } = useFileContext();
const [activeGroupId, setActiveGroupId] = useState<string | null>(null); const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [activeImageId, setActiveImageId] = useState<string | null>(null); const [activeImageId, setActiveImageId] = useState<string | null>(null);
@@ -375,6 +380,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
fileName, fileName,
errorMessage, errorMessage,
isGeneratingPdf, isGeneratingPdf,
isSavingToWorkbench,
isConverting, isConverting,
conversionProgress, conversionProgress,
hasChanges, hasChanges,
@@ -389,11 +395,12 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
onReset, onReset,
onDownloadJson, onDownloadJson,
onGeneratePdf, onGeneratePdf,
onGeneratePdfForNavigation, onSaveToWorkbench,
onForceSingleTextElementChange, onForceSingleTextElementChange,
onGroupingModeChange, onGroupingModeChange,
onMergeGroups, onMergeGroups,
onUngroupGroup, onUngroupGroup,
onLoadFile,
} = data; } = data;
// Define derived variables immediately after props destructuring, before any hooks // Define derived variables immediately after props destructuring, before any hooks
@@ -1430,7 +1437,8 @@ const selectionToolbarPosition = useMemo(() => {
height: '100%', height: '100%',
display: 'grid', display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px', gridTemplateColumns: 'minmax(0, 1fr) 320px',
alignItems: 'start', gridTemplateRows: '1fr',
alignItems: hasDocument ? 'start' : 'stretch',
gap: '1.5rem', gap: '1.5rem',
}} }}
> >
@@ -1486,6 +1494,17 @@ const selectionToolbarPosition = useMemo(() => {
> >
{t('pdfTextEditor.actions.generatePdf', 'Generate PDF')} {t('pdfTextEditor.actions.generatePdf', 'Generate PDF')}
</Button> </Button>
<Button
variant="filled"
color="green"
leftSection={<SaveIcon fontSize="small" />}
onClick={onSaveToWorkbench}
loading={isSavingToWorkbench}
disabled={!hasDocument || !hasChanges || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.saveChanges', 'Save Changes')}
</Button>
</Stack> </Stack>
{fileName && ( {fileName && (
@@ -1639,17 +1658,45 @@ const selectionToolbarPosition = useMemo(() => {
)} )}
{!hasDocument && !isConverting && ( {!hasDocument && !isConverting && (
<Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}> <Stack
<Stack align="center" gap="md"> align="center"
<DescriptionIcon sx={{ fontSize: 48 }} /> justify="center"
<Text size="lg" fw={600}> style={{ gridColumn: '1 / 2', gridRow: 1, height: '100%' }}
{t('pdfTextEditor.empty.title', 'No document loaded')} >
</Text> <Dropzone
<Text size="sm" c="dimmed" ta="center" maw={420}> onDrop={(files) => {
{t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')} if (files.length > 0) {
</Text> onLoadFile(files[0]);
</Stack> }
</Card> }}
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',
}}
>
<Stack align="center" gap="md" style={{ pointerEvents: 'none' }}>
<UploadFileIcon sx={{ fontSize: 48, color: 'var(--mantine-color-blue-5)' }} />
<Text size="lg" fw={600}>
{t('pdfTextEditor.empty.title', 'No document loaded')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
{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')}
</Text>
</Stack>
</Dropzone>
</Stack>
)} )}
{isConverting && ( {isConverting && (
@@ -1683,7 +1730,7 @@ const selectionToolbarPosition = useMemo(() => {
</Card> </Card>
)} )}
{hasDocument && ( {hasDocument && !isConverting && (
<Stack <Stack
gap="lg" gap="lg"
className="flex-1" className="flex-1"
@@ -2444,7 +2491,7 @@ const selectionToolbarPosition = useMemo(() => {
{/* Navigation Warning Modal */} {/* Navigation Warning Modal */}
<NavigationWarningModal <NavigationWarningModal
onApplyAndContinue={onGeneratePdfForNavigation} onApplyAndContinue={onSaveToWorkbench}
/> />
</Stack> </Stack>
); );
@@ -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 { WorkbenchType, getDefaultWorkbench } from '@app/types/workbench';
import { ToolId, isValidToolId } from '@app/types/toolId'; import { ToolId, isValidToolId } from '@app/types/toolId';
import { useToolRegistry } from '@app/contexts/ToolRegistryContext'; import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
@@ -110,8 +110,8 @@ export const NavigationProvider: React.FC<{
const { allTools: toolRegistry } = useToolRegistry(); const { allTools: toolRegistry } = useToolRegistry();
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null); const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
const actions: NavigationContextActions = { // Memoize individual callbacks
setWorkbench: useCallback((workbench: WorkbenchType) => { const setWorkbench = useCallback((workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state // Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
console.log('[NavigationContext] setWorkbench:', { console.log('[NavigationContext] setWorkbench:', {
@@ -152,13 +152,13 @@ export const NavigationProvider: React.FC<{
} else { } else {
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); 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 } }); 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 // Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
@@ -177,25 +177,25 @@ export const NavigationProvider: React.FC<{
} else { } else {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } }); 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 } }); dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []), }, []);
registerUnsavedChangesChecker: useCallback((checker: () => boolean) => { const registerUnsavedChangesChecker = useCallback((checker: () => boolean) => {
unsavedChangesCheckerRef.current = checker; unsavedChangesCheckerRef.current = checker;
}, []), }, []);
unregisterUnsavedChangesChecker: useCallback(() => { const unregisterUnsavedChangesChecker = useCallback(() => {
unsavedChangesCheckerRef.current = null; unsavedChangesCheckerRef.current = null;
}, []), }, []);
showNavigationWarning: useCallback((show: boolean) => { const showNavigationWarning = useCallback((show: boolean) => {
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
}, []), }, []);
requestNavigation: useCallback((navigationFn: () => void) => { const requestNavigation = useCallback((navigationFn: () => void) => {
if (!state.hasUnsavedChanges) { if (!state.hasUnsavedChanges) {
navigationFn(); navigationFn();
return; return;
@@ -203,9 +203,9 @@ export const NavigationProvider: React.FC<{
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(() => { const confirmNavigation = useCallback(() => {
console.log('[NavigationContext] confirmNavigation called', { console.log('[NavigationContext] confirmNavigation called', {
hasPendingNav: !!state.pendingNavigation, hasPendingNav: !!state.pendingNavigation,
currentWorkbench: state.workbench, currentWorkbench: state.workbench,
@@ -218,18 +218,18 @@ export const NavigationProvider: React.FC<{
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 } });
console.log('[NavigationContext] confirmNavigation completed'); 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: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, []), }, []);
clearToolSelection: useCallback(() => { const clearToolSelection = useCallback(() => {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } }); dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
}, []), }, []);
handleToolSelect: useCallback((toolId: string) => { const handleToolSelect = useCallback((toolId: string) => {
if (toolId === 'allTools') { if (toolId === 'allTools') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } }); dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
return; return;
@@ -245,11 +245,40 @@ export const NavigationProvider: React.FC<{
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null; const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench(); const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
// Validate toolId and convert to ToolId type // Validate toolId and convert to ToolId type
const validToolId = isValidToolId(toolId) ? toolId : null; const validToolId = isValidToolId(toolId) ? toolId : null;
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } }); dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
}, [toolRegistry]) }, [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 = { const stateValue: NavigationContextStateValue = {
workbench: state.workbench, workbench: state.workbench,
@@ -259,9 +288,10 @@ export const NavigationProvider: React.FC<{
showNavigationWarning: state.showNavigationWarning showNavigationWarning: state.showNavigationWarning
}; };
const actionsValue: NavigationContextActionsValue = { // Also memoize the context value to prevent unnecessary re-renders
const actionsValue: NavigationContextActionsValue = useMemo(() => ({
actions actions
}; }), [actions]);
return ( return (
<NavigationStateContext.Provider value={stateValue}> <NavigationStateContext.Provider value={stateValue}>
@@ -224,11 +224,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
return; return;
} }
if (navigationState.pendingNavigation || navigationState.showNavigationWarning) {
return;
}
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench); const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
if (!currentCustomView || currentCustomView.data == null) { if (!currentCustomView || currentCustomView.data == null) {
actions.setWorkbench(getDefaultWorkbench()); actions.setWorkbench(getDefaultWorkbench());
} }
}, [actions, customWorkbenchViews, navigationState.workbench]); }, [actions, customWorkbenchViews, navigationState.workbench, navigationState.pendingNavigation, navigationState.showNavigationWarning]);
// Persisted via PreferencesContext; no direct localStorage writes needed here // Persisted via PreferencesContext; no direct localStorage writes needed here
@@ -152,6 +152,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
// Proprietary tools (if any) // Proprietary tools (if any)
...proprietaryTools, ...proprietaryTools,
// Recommended Tools in order // Recommended Tools in order
pdfTextEditor: {
icon: <LocalIcon icon="edit-square-outline-rounded" width="1.5rem" height="1.5rem" />,
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: { multiTool: {
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.multiTool.title", "Multi-Tool"), name: t("home.multiTool.title", "Multi-Tool"),
@@ -893,23 +910,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: RedactSingleStepSettings, automationSettings: RedactSingleStepSettings,
synonyms: getSynonyms(t, "redact") synonyms: getSynonyms(t, "redact")
}, },
pdfTextEditor: {
icon: <LocalIcon icon="edit-square-outline-rounded" width="1.5rem" height="1.5rem" />,
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; const regularTools = {} as RegularToolRegistry;
+7 -3
View File
@@ -59,17 +59,21 @@ export default function HomePage() {
const prevFileCountRef = useRef(activeFiles.length); const prevFileCountRef = useRef(activeFiles.length);
// Auto-switch to viewer when going from 0 to 1 file // 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(() => { useEffect(() => {
const prevCount = prevFileCountRef.current; const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length; const currentCount = activeFiles.length;
if (prevCount === 0 && currentCount === 1) { if (prevCount === 0 && currentCount === 1) {
actions.setWorkbench('viewer'); // PDF Text Editor handles its own empty state with a dropzone
setActiveFileIndex(0); if (selectedToolKey !== 'pdfTextEditor') {
actions.setWorkbench('viewer');
setActiveFileIndex(0);
}
} }
prevFileCountRef.current = currentCount; prevFileCountRef.current = currentCount;
}, [activeFiles.length, actions, setActiveFileIndex]); }, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath(); const brandIconSrc = useLogoPath();
@@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined'; import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; 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 { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import { BaseToolProps, ToolComponent } from '@app/types/tool'; import { BaseToolProps, ToolComponent } from '@app/types/tool';
import { getDefaultWorkbench } from '@app/types/workbench';
import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants'; import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants';
import apiClient from '@app/services/apiClient'; import apiClient from '@app/services/apiClient';
import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils'; import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils';
@@ -208,7 +210,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
} = useToolWorkflow(); } = useToolWorkflow();
const { actions: navigationActions } = useNavigationActions(); const { actions: navigationActions } = useNavigationActions();
const navigationState = useNavigationState(); const navigationState = useNavigationState();
const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = navigationActions; const { addFiles } = useFileManagement();
const { consumeFiles, selectors } = useFileContext();
const [loadedDocument, setLoadedDocument] = useState<PdfJsonDocument | null>(null); const [loadedDocument, setLoadedDocument] = useState<PdfJsonDocument | null>(null);
const [groupsByPage, setGroupsByPage] = useState<TextGroup[][]>([]); const [groupsByPage, setGroupsByPage] = useState<TextGroup[][]>([]);
@@ -217,6 +220,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isSavingToWorkbench, setIsSavingToWorkbench] = useState(false);
const [shouldNavigateAfterSave, setShouldNavigateAfterSave] = useState(false);
const [isConverting, setIsConverting] = useState(false); const [isConverting, setIsConverting] = useState(false);
const [conversionProgress, setConversionProgress] = useState<ConversionProgress | null>(null); const [conversionProgress, setConversionProgress] = useState<ConversionProgress | null>(null);
const [forceSingleTextElement, setForceSingleTextElement] = useState(true); const [forceSingleTextElement, setForceSingleTextElement] = useState(true);
@@ -234,6 +239,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const originalGroupsRef = useRef<TextGroup[][]>([]); const originalGroupsRef = useRef<TextGroup[][]>([]);
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]); const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
const autoLoadKeyRef = useRef<string | null>(null); const autoLoadKeyRef = useRef<string | null>(null);
const sourceFileIdRef = useRef<string | null>(null);
const loadRequestIdRef = useRef(0); const loadRequestIdRef = useRef(0);
const latestPdfRequestIdRef = useRef<number | null>(null); const latestPdfRequestIdRef = useRef<number | null>(null);
const loadedDocumentRef = useRef<PdfJsonDocument | null>(null); const loadedDocumentRef = useRef<PdfJsonDocument | null>(null);
@@ -279,6 +285,23 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
); );
const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]); const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]);
const hasDocument = loadedDocument !== null; 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 viewLabel = useMemo(() => t('pdfTextEditor.viewLabel', 'PDF Editor'), [t]);
const { selectedFiles } = useFileSelection(); const { selectedFiles } = useFileSelection();
@@ -720,6 +743,21 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
[groupingMode, resetToDocument, t], [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) => { const handleSelectPage = useCallback((pageIndex: number) => {
setSelectedPage(pageIndex); setSelectedPage(pageIndex);
// Trigger lazy loading for images on the selected page // Trigger lazy loading for images on the selected page
@@ -1122,6 +1160,229 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
t, 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( const requestPagePreview = useCallback(
async (pageIndex: number, scale: number) => { async (pageIndex: number, scale: number) => {
if (!hasVectorPreview || !pdfDocumentRef.current) { if (!hasVectorPreview || !pdfDocumentRef.current) {
@@ -1260,6 +1521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
fileName, fileName,
errorMessage, errorMessage,
isGeneratingPdf, isGeneratingPdf,
isSavingToWorkbench,
isConverting, isConverting,
conversionProgress, conversionProgress,
hasChanges, hasChanges,
@@ -1278,15 +1540,19 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
// Generate PDF without triggering tool completion // Generate PDF without triggering tool completion
await handleGeneratePdf(true); await handleGeneratePdf(true);
}, },
onSaveToWorkbench: handleSaveToWorkbench,
onForceSingleTextElementChange: setForceSingleTextElement, onForceSingleTextElementChange: setForceSingleTextElement,
onGroupingModeChange: setGroupingMode, onGroupingModeChange: setGroupingMode,
onMergeGroups: handleMergeGroups, onMergeGroups: handleMergeGroups,
onUngroupGroup: handleUngroupGroup, onUngroupGroup: handleUngroupGroup,
onLoadFile: handleLoadFileFromDropzone,
}), [ }), [
handleMergeGroups, handleMergeGroups,
handleUngroupGroup, handleUngroupGroup,
handleImageTransform, handleImageTransform,
handleSaveToWorkbench,
imagesByPage, imagesByPage,
isSavingToWorkbench,
pagePreviews, pagePreviews,
dirtyPages, dirtyPages,
errorMessage, errorMessage,
@@ -1311,6 +1577,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
groupingMode, groupingMode,
requestPagePreview, requestPagePreview,
setForceSingleTextElement, setForceSingleTextElement,
handleLoadFileFromDropzone,
]); ]);
const latestViewDataRef = useRef<PdfTextEditorViewData>(viewData); const latestViewDataRef = useRef<PdfTextEditorViewData>(viewData);
@@ -1326,6 +1593,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
useEffect(() => { useEffect(() => {
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
autoLoadKeyRef.current = null; autoLoadKeyRef.current = null;
sourceFileIdRef.current = null;
return; return;
} }
@@ -1344,6 +1612,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
} }
autoLoadKeyRef.current = fileKey; autoLoadKeyRef.current = fileKey;
// Capture the source file ID for save-to-workbench functionality
sourceFileIdRef.current = (file as any).fileId ?? null;
void handleLoadFile(file); void handleLoadFile(file);
}, [selectedFiles, navigationState.selectedTool, handleLoadFile]); }, [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 // The workbench should be set when the tool is selected via proper channels
// (tool registry, tool picker, etc.) - not forced here // (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<PdfTextEditorViewData | null>(null); const lastSentViewDataRef = useRef<PdfTextEditorViewData | null>(null);
useEffect(() => { useEffect(() => {
@@ -221,8 +221,11 @@ export interface PdfTextEditorViewData {
onDownloadJson: () => void; onDownloadJson: () => void;
onGeneratePdf: () => void; onGeneratePdf: () => void;
onGeneratePdfForNavigation: () => Promise<void>; onGeneratePdfForNavigation: () => Promise<void>;
onSaveToWorkbench: () => Promise<void>;
isSavingToWorkbench: boolean;
onForceSingleTextElementChange: (value: boolean) => void; onForceSingleTextElementChange: (value: boolean) => void;
onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void; onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void;
onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean; onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean;
onUngroupGroup: (pageIndex: number, groupId: string) => boolean; onUngroupGroup: (pageIndex: number, groupId: string) => boolean;
onLoadFile: (file: File) => void;
} }