Files
Stirling-PDF/frontend/src/core/tools/EditTableOfContents.tsx
Anthony Stirling 87bf7a5b7f 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 <74901996+reecebrowne@users.noreply.github.com>
2025-11-18 15:07:12 +00:00

395 lines
14 KiB
TypeScript

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;