mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
Anthony Stirling
parent
f8dbf171e1
commit
c9bf436895
@@ -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<PdfJsonDocument | null>(null);
|
||||
const [groupsByPage, setGroupsByPage] = useState<TextGroup[][]>([]);
|
||||
@@ -217,6 +220,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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<ConversionProgress | null>(null);
|
||||
const [forceSingleTextElement, setForceSingleTextElement] = useState(true);
|
||||
@@ -234,6 +239,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const originalGroupsRef = useRef<TextGroup[][]>([]);
|
||||
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
|
||||
const autoLoadKeyRef = useRef<string | null>(null);
|
||||
const sourceFileIdRef = useRef<string | null>(null);
|
||||
const loadRequestIdRef = useRef(0);
|
||||
const latestPdfRequestIdRef = useRef<number | 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 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<PdfTextEditorViewData>(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<PdfTextEditorViewData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -221,8 +221,11 @@ export interface PdfTextEditorViewData {
|
||||
onDownloadJson: () => void;
|
||||
onGeneratePdf: () => void;
|
||||
onGeneratePdfForNavigation: () => Promise<void>;
|
||||
onSaveToWorkbench: () => Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user