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')} + {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; }