mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
Add edit table of contents tool to React UI (#4917)
## Summary - add a dedicated edit table of contents tool to the React UI, complete with bookmark editor, import/export actions, and parameter handling - register the tool in the translated registry and extend the English translations with the new strings - wire up the backend endpoints through a new operation hook and form-data serialization helpers ## Testing - ./gradlew build ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_691a4a87a9c4832899ecd1c55989f27f) --------- Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
parent
a8ea0b60cf
commit
87bf7a5b7f
@@ -0,0 +1,394 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||
import EditTableOfContentsWorkbenchView, { EditTableOfContentsWorkbenchViewData } from '@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView';
|
||||
import EditTableOfContentsSettings from '@app/components/tools/editTableOfContents/EditTableOfContentsSettings';
|
||||
import { useEditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters';
|
||||
import { useEditTableOfContentsOperation } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
|
||||
import { BaseToolProps, ToolComponent } from '@app/types/tool';
|
||||
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { BookmarkPayload, BookmarkNode, hydrateBookmarkPayload, serializeBookmarkNodes } from '@app/utils/editTableOfContents';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useFileSelection } from '@app/contexts/FileContext';
|
||||
|
||||
const extractBookmarks = async (file: File): Promise<BookmarkPayload[]> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post('/api/v1/general/extract-bookmarks', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
return response.data as BookmarkPayload[];
|
||||
};
|
||||
|
||||
const useStableCallback = <T extends (...args: any[]) => any>(callback: T): T => {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
return useMemo(() => ((...args: Parameters<T>) => callbackRef.current(...args)) as T, []);
|
||||
};
|
||||
|
||||
const EditTableOfContents = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const base = useBaseTool(
|
||||
'edit-table-of-contents',
|
||||
useEditTableOfContentsParameters,
|
||||
useEditTableOfContentsOperation,
|
||||
props,
|
||||
{ minFiles: 1 }
|
||||
);
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { clearSelections } = useFileSelection();
|
||||
const navigationState = useNavigationState();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
|
||||
const WORKBENCH_VIEW_ID = 'editTableOfContentsWorkbench';
|
||||
const WORKBENCH_ID = 'custom:editTableOfContents' as const;
|
||||
const viewIcon = useMemo(() => <MenuBookRoundedIcon fontSize="small" />, []);
|
||||
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false);
|
||||
const [lastLoadedFileId, setLastLoadedFileId] = useState<string | null>(null);
|
||||
const hasAutoOpenedWorkbenchRef = useRef(false);
|
||||
|
||||
const selectedFile = base.selectedFiles[0];
|
||||
|
||||
const { setBookmarks } = base.params;
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: WORKBENCH_VIEW_ID,
|
||||
workbenchId: WORKBENCH_ID,
|
||||
label: 'Outline workspace',
|
||||
icon: viewIcon,
|
||||
component: EditTableOfContentsWorkbenchView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
|
||||
};
|
||||
// Register once; avoid re-registering which clears data mid-flight
|
||||
}, []);
|
||||
|
||||
const loadBookmarksForFile = useCallback(async (file: File, { showToast }: { showToast?: boolean } = {}) => {
|
||||
setIsLoadingBookmarks(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
const payload = await extractBookmarks(file);
|
||||
const bookmarks = hydrateBookmarkPayload(payload);
|
||||
setBookmarks(bookmarks);
|
||||
setLastLoadedFileId((file as any)?.fileId ?? file.name);
|
||||
|
||||
if (showToast) {
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.loadedTitle', 'Bookmarks extracted'),
|
||||
body: t('editTableOfContents.messages.loadedBody', 'Existing bookmarks from the PDF were loaded into the editor.'),
|
||||
alertType: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
setLoadError(t('editTableOfContents.messages.noBookmarks', 'No bookmarks were found in the selected PDF.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks', error);
|
||||
setLoadError(t('editTableOfContents.messages.loadFailed', 'Unable to extract bookmarks from the selected PDF.'));
|
||||
} finally {
|
||||
setIsLoadingBookmarks(false);
|
||||
}
|
||||
}, [setBookmarks, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't auto-load bookmarks if we have results - user is viewing the output
|
||||
if (base.hasResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
setBookmarks([]);
|
||||
setLastLoadedFileId(null);
|
||||
setLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileId = (selectedFile as any)?.fileId ?? selectedFile.name;
|
||||
if (fileId === lastLoadedFileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadBookmarksForFile(selectedFile).catch(() => {
|
||||
// errors handled in hook
|
||||
});
|
||||
}, [selectedFile, lastLoadedFileId, loadBookmarksForFile, setBookmarks, base.hasResults]);
|
||||
|
||||
const importJsonCallback = async (file: File) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text) as BookmarkPayload[];
|
||||
setBookmarks(hydrateBookmarkPayload(json));
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.imported', 'Bookmarks imported'),
|
||||
body: t('editTableOfContents.messages.importedBody', 'Your JSON outline replaced the current editor contents.'),
|
||||
alertType: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to import JSON bookmarks', error);
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'),
|
||||
body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'),
|
||||
alertType: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleImportJson = useStableCallback(importJsonCallback);
|
||||
|
||||
const importClipboardCallback = async () => {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
alert({
|
||||
title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'),
|
||||
alertType: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboard = await navigator.clipboard.readText();
|
||||
const json = JSON.parse(clipboard) as BookmarkPayload[];
|
||||
setBookmarks(hydrateBookmarkPayload(json));
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.imported', 'Bookmarks imported'),
|
||||
body: t('editTableOfContents.messages.importedClipboard', 'Clipboard data replaced the current bookmark list.'),
|
||||
alertType: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to import bookmarks from clipboard', error);
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'),
|
||||
body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'),
|
||||
alertType: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleImportClipboard = useStableCallback(importClipboardCallback);
|
||||
|
||||
const exportJsonCallback = () => {
|
||||
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'bookmarks.json';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.exported', 'JSON download ready'),
|
||||
alertType: 'success',
|
||||
});
|
||||
};
|
||||
const handleExportJson = useStableCallback(exportJsonCallback);
|
||||
|
||||
const exportClipboardCallback = async () => {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
alert({
|
||||
title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'),
|
||||
alertType: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(data);
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.copied', 'Copied to clipboard'),
|
||||
body: t('editTableOfContents.messages.copiedBody', 'Bookmark JSON copied successfully.'),
|
||||
alertType: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy bookmarks', error);
|
||||
alert({
|
||||
title: t('editTableOfContents.messages.copyFailed', 'Copy failed'),
|
||||
alertType: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleExportClipboard = useStableCallback(exportClipboardCallback);
|
||||
|
||||
const clipboardReadAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText);
|
||||
const clipboardWriteAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.writeText);
|
||||
|
||||
const loadFromSelectedCallback = () => {
|
||||
if (selectedFile) {
|
||||
loadBookmarksForFile(selectedFile, { showToast: true });
|
||||
}
|
||||
};
|
||||
const handleLoadFromSelected = useStableCallback(loadFromSelectedCallback);
|
||||
|
||||
const replaceExistingCallback = (value: boolean) => {
|
||||
base.params.updateParameter('replaceExisting', value);
|
||||
};
|
||||
const handleReplaceExistingChange = useStableCallback(replaceExistingCallback);
|
||||
|
||||
const bookmarksChangeCallback = (bookmarks: BookmarkNode[]) => {
|
||||
setBookmarks(bookmarks);
|
||||
};
|
||||
const handleBookmarksChange = useStableCallback(bookmarksChangeCallback);
|
||||
|
||||
const executeCallback = () => {
|
||||
void base.handleExecute();
|
||||
};
|
||||
const handleExecute = useStableCallback(executeCallback);
|
||||
|
||||
const undoCallback = () => {
|
||||
base.handleUndo();
|
||||
};
|
||||
const handleUndo = useStableCallback(undoCallback);
|
||||
|
||||
const clearErrorCallback = () => {
|
||||
base.operation.clearError();
|
||||
};
|
||||
const handleClearError = useStableCallback(clearErrorCallback);
|
||||
|
||||
const fileClickCallback = (file: File) => {
|
||||
base.handleThumbnailClick(file);
|
||||
};
|
||||
const handleFileClick = useStableCallback(fileClickCallback);
|
||||
|
||||
const selectFilesCallback = () => {
|
||||
// Clear existing selection first so the new file replaces instead of adds
|
||||
clearSelections();
|
||||
openFilesModal();
|
||||
};
|
||||
const handleSelectFiles = useStableCallback(selectFilesCallback);
|
||||
|
||||
// Always keep workbench data updated
|
||||
useEffect(() => {
|
||||
const data: EditTableOfContentsWorkbenchViewData = {
|
||||
bookmarks: base.params.parameters.bookmarks,
|
||||
selectedFileName: selectedFile?.name,
|
||||
disabled: base.endpointLoading || base.operation.isLoading,
|
||||
files: base.operation.files ?? [],
|
||||
thumbnails: base.operation.thumbnails ?? [],
|
||||
downloadUrl: base.operation.downloadUrl ?? null,
|
||||
downloadFilename: base.operation.downloadFilename ?? null,
|
||||
errorMessage: base.operation.errorMessage ?? null,
|
||||
isGeneratingThumbnails: base.operation.isGeneratingThumbnails,
|
||||
isExecuteDisabled:
|
||||
!selectedFile ||
|
||||
!base.hasFiles ||
|
||||
base.endpointEnabled === false ||
|
||||
base.operation.isLoading ||
|
||||
base.endpointLoading,
|
||||
isExecuting: base.operation.isLoading,
|
||||
onClearError: handleClearError,
|
||||
onBookmarksChange: handleBookmarksChange,
|
||||
onExecute: handleExecute,
|
||||
onUndo: handleUndo,
|
||||
onFileClick: handleFileClick,
|
||||
};
|
||||
|
||||
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, data);
|
||||
}, [
|
||||
WORKBENCH_VIEW_ID,
|
||||
base.endpointEnabled,
|
||||
base.endpointLoading,
|
||||
base.hasFiles,
|
||||
base.operation.downloadFilename,
|
||||
base.operation.downloadUrl,
|
||||
base.operation.errorMessage,
|
||||
base.operation.files,
|
||||
base.operation.isGeneratingThumbnails,
|
||||
base.operation.isLoading,
|
||||
base.operation.thumbnails,
|
||||
base.params.parameters.bookmarks,
|
||||
handleBookmarksChange,
|
||||
handleClearError,
|
||||
handleExecute,
|
||||
handleFileClick,
|
||||
handleUndo,
|
||||
selectedFile,
|
||||
setCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
// Auto-navigate to workbench when tool is selected
|
||||
useEffect(() => {
|
||||
if (navigationState.selectedTool !== 'editTableOfContents') {
|
||||
hasAutoOpenedWorkbenchRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAutoOpenedWorkbenchRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAutoOpenedWorkbenchRef.current = true;
|
||||
// Use timeout to ensure data effect has run first
|
||||
setTimeout(() => {
|
||||
navigationActions.setWorkbench(WORKBENCH_ID);
|
||||
}, 0);
|
||||
}, [navigationActions, navigationState.selectedTool, WORKBENCH_ID]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: true,
|
||||
minFiles: 1,
|
||||
isVisible: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t('editTableOfContents.settings.title', 'Bookmarks & outline'),
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<EditTableOfContentsSettings
|
||||
bookmarks={base.params.parameters.bookmarks}
|
||||
replaceExisting={base.params.parameters.replaceExisting}
|
||||
onReplaceExistingChange={handleReplaceExistingChange}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
onLoadFromPdf={handleLoadFromSelected}
|
||||
onImportJson={handleImportJson}
|
||||
onImportClipboard={handleImportClipboard}
|
||||
onExportJson={handleExportJson}
|
||||
onExportClipboard={handleExportClipboard}
|
||||
isLoading={isLoadingBookmarks}
|
||||
loadError={loadError}
|
||||
canReadClipboard={clipboardReadAvailable}
|
||||
canWriteClipboard={clipboardWriteAvailable}
|
||||
disabled={base.endpointLoading}
|
||||
selectedFileName={selectedFile?.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t('editTableOfContents.results.title', 'Updated PDF with bookmarks'),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
(EditTableOfContents as any).tool = () => useEditTableOfContentsOperation;
|
||||
|
||||
export default EditTableOfContents as ToolComponent;
|
||||
Reference in New Issue
Block a user