mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
## 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>
395 lines
14 KiB
TypeScript
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;
|