mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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
+4
-6
@@ -29,7 +29,6 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.SPDF.config.swagger.JsonDataResponse;
|
|
||||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||||
@@ -49,13 +48,12 @@ public class EditTableOfContentsController {
|
|||||||
@AutoJobPostMapping(
|
@AutoJobPostMapping(
|
||||||
value = "/extract-bookmarks",
|
value = "/extract-bookmarks",
|
||||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@JsonDataResponse
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Extract PDF Bookmarks",
|
summary = "Extract PDF Bookmarks",
|
||||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
|
||||||
throws Exception {
|
@RequestParam("file") MultipartFile file) throws Exception {
|
||||||
PDDocument document = null;
|
PDDocument document = null;
|
||||||
try {
|
try {
|
||||||
document = pdfDocumentFactory.load(file);
|
document = pdfDocumentFactory.load(file);
|
||||||
@@ -63,10 +61,10 @@ public class EditTableOfContentsController {
|
|||||||
|
|
||||||
if (outline == null) {
|
if (outline == null) {
|
||||||
log.info("No outline/bookmarks found in PDF");
|
log.info("No outline/bookmarks found in PDF");
|
||||||
return new ArrayList<>();
|
return ResponseEntity.ok(new ArrayList<>());
|
||||||
}
|
}
|
||||||
|
|
||||||
return extractBookmarkItems(document, outline);
|
return ResponseEntity.ok(extractBookmarkItems(document, outline));
|
||||||
} finally {
|
} finally {
|
||||||
if (document != null) {
|
if (document != null) {
|
||||||
document.close();
|
document.close();
|
||||||
|
|||||||
+21
-4
@@ -24,6 +24,7 @@ import org.mockito.ArgumentMatchers;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
@@ -86,9 +87,13 @@ class EditTableOfContentsControllerTest {
|
|||||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
ResponseEntity<List<Map<String, Object>>> response =
|
||||||
|
editTableOfContentsController.extractBookmarks(mockFile);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
List<Map<String, Object>> result = response.getBody();
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
assertEquals(1, result.size());
|
assertEquals(1, result.size());
|
||||||
|
|
||||||
@@ -108,9 +113,13 @@ class EditTableOfContentsControllerTest {
|
|||||||
when(mockCatalog.getDocumentOutline()).thenReturn(null);
|
when(mockCatalog.getDocumentOutline()).thenReturn(null);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
ResponseEntity<List<Map<String, Object>>> response =
|
||||||
|
editTableOfContentsController.extractBookmarks(mockFile);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
List<Map<String, Object>> result = response.getBody();
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
assertTrue(result.isEmpty());
|
assertTrue(result.isEmpty());
|
||||||
verify(mockDocument).close();
|
verify(mockDocument).close();
|
||||||
@@ -142,9 +151,13 @@ class EditTableOfContentsControllerTest {
|
|||||||
when(childItem.getNextSibling()).thenReturn(null);
|
when(childItem.getNextSibling()).thenReturn(null);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
ResponseEntity<List<Map<String, Object>>> response =
|
||||||
|
editTableOfContentsController.extractBookmarks(mockFile);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
List<Map<String, Object>> result = response.getBody();
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
assertEquals(1, result.size());
|
assertEquals(1, result.size());
|
||||||
|
|
||||||
@@ -178,9 +191,13 @@ class EditTableOfContentsControllerTest {
|
|||||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
ResponseEntity<List<Map<String, Object>>> response =
|
||||||
|
editTableOfContentsController.extractBookmarks(mockFile);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
List<Map<String, Object>> result = response.getBody();
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
assertEquals(1, result.size());
|
assertEquals(1, result.size());
|
||||||
|
|
||||||
|
|||||||
@@ -1427,6 +1427,93 @@
|
|||||||
},
|
},
|
||||||
"submit": "Change"
|
"submit": "Change"
|
||||||
},
|
},
|
||||||
|
"editTableOfContents": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Bookmarks & outline",
|
||||||
|
"replaceExisting": "Replace existing bookmarks (uncheck to append)",
|
||||||
|
"replaceExistingHint": "When disabled, the new outline is appended after the current bookmarks."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"source": "Load bookmarks",
|
||||||
|
"selectedFile": "Loaded from {{file}}",
|
||||||
|
"noFile": "Select a PDF to extract existing bookmarks.",
|
||||||
|
"loadFromPdf": "Load from selected PDF",
|
||||||
|
"importJson": "Import JSON",
|
||||||
|
"importClipboard": "Paste JSON from clipboard",
|
||||||
|
"export": "Export bookmarks",
|
||||||
|
"exportJson": "Download JSON",
|
||||||
|
"exportClipboard": "Copy JSON to clipboard",
|
||||||
|
"clipboardUnavailable": "Clipboard access is not available in this browser."
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"line1": "Each bookmark needs a descriptive title and the page it should open.",
|
||||||
|
"line2": "Use child bookmarks to build a hierarchy for chapters, sections, or subsections.",
|
||||||
|
"line3": "Import bookmarks from the selected PDF or from a JSON file to save time."
|
||||||
|
},
|
||||||
|
"workbench": {
|
||||||
|
"empty": {
|
||||||
|
"title": "Open the tool to start editing",
|
||||||
|
"description": "Select the Edit Table of Contents tool to load its workspace."
|
||||||
|
},
|
||||||
|
"tabTitle": "Outline workspace",
|
||||||
|
"subtitle": "Import bookmarks, build hierarchies, and apply the outline without cramped side panels.",
|
||||||
|
"noFile": "No PDF selected",
|
||||||
|
"fileLabel": "Changes will be applied to the currently selected PDF.",
|
||||||
|
"filePrompt": "Select a PDF from your library or upload a new one to begin.",
|
||||||
|
"changeFile": "Change PDF",
|
||||||
|
"selectFile": "Select PDF"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"heading": "Bookmark editor",
|
||||||
|
"description": "Add, nest, and reorder bookmarks to craft your PDF outline.",
|
||||||
|
"addTopLevel": "Add top-level bookmark",
|
||||||
|
"empty": {
|
||||||
|
"title": "No bookmarks yet",
|
||||||
|
"description": "Import existing bookmarks or start by adding your first entry.",
|
||||||
|
"action": "Add first bookmark"
|
||||||
|
},
|
||||||
|
"defaultTitle": "New bookmark",
|
||||||
|
"defaultChildTitle": "Child bookmark",
|
||||||
|
"defaultSiblingTitle": "New bookmark",
|
||||||
|
"untitled": "Untitled bookmark",
|
||||||
|
"childBadge": "Child",
|
||||||
|
"pagePreview": "Page {{page}}",
|
||||||
|
"field": {
|
||||||
|
"title": "Bookmark title",
|
||||||
|
"page": "Target page number"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"toggle": "Toggle children",
|
||||||
|
"addChild": "Add child bookmark",
|
||||||
|
"addSibling": "Add sibling bookmark",
|
||||||
|
"remove": "Remove bookmark"
|
||||||
|
},
|
||||||
|
"confirmRemove": "Remove this bookmark and all of its children?"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"loadedTitle": "Bookmarks extracted",
|
||||||
|
"loadedBody": "Existing bookmarks from the PDF were loaded into the editor.",
|
||||||
|
"noBookmarks": "No bookmarks were found in the selected PDF.",
|
||||||
|
"loadFailed": "Unable to extract bookmarks from the selected PDF.",
|
||||||
|
"imported": "Bookmarks imported",
|
||||||
|
"importedBody": "Your JSON outline replaced the current editor contents.",
|
||||||
|
"importedClipboard": "Clipboard data replaced the current bookmark list.",
|
||||||
|
"invalidJson": "Invalid JSON structure",
|
||||||
|
"invalidJsonBody": "Please provide a valid bookmark JSON file and try again.",
|
||||||
|
"exported": "JSON download ready",
|
||||||
|
"copied": "Copied to clipboard",
|
||||||
|
"copiedBody": "Bookmark JSON copied successfully.",
|
||||||
|
"copyFailed": "Copy failed"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to update the table of contents"
|
||||||
|
},
|
||||||
|
"submit": "Apply table of contents",
|
||||||
|
"results": {
|
||||||
|
"title": "Updated PDF with bookmarks",
|
||||||
|
"subtitle": "Download the processed file or undo the operation below."
|
||||||
|
}
|
||||||
|
},
|
||||||
"removePages": {
|
"removePages": {
|
||||||
"tags": "Remove pages,delete pages",
|
"tags": "Remove pages,delete pages",
|
||||||
"title": "Remove Pages",
|
"title": "Remove Pages",
|
||||||
|
|||||||
@@ -1397,6 +1397,76 @@
|
|||||||
},
|
},
|
||||||
"submit": "Change"
|
"submit": "Change"
|
||||||
},
|
},
|
||||||
|
"editTableOfContents": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Bookmarks & outline",
|
||||||
|
"replaceExisting": "Replace existing bookmarks (uncheck to append)",
|
||||||
|
"replaceExistingHint": "When disabled, the new outline is appended after the current bookmarks."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"source": "Load bookmarks",
|
||||||
|
"selectedFile": "Loaded from {{file}}",
|
||||||
|
"noFile": "Select a PDF to extract existing bookmarks.",
|
||||||
|
"loadFromPdf": "Load from selected PDF",
|
||||||
|
"importJson": "Import JSON",
|
||||||
|
"importClipboard": "Paste JSON from clipboard",
|
||||||
|
"export": "Export bookmarks",
|
||||||
|
"exportJson": "Download JSON",
|
||||||
|
"exportClipboard": "Copy JSON to clipboard",
|
||||||
|
"clipboardUnavailable": "Clipboard access is not available in this browser."
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"line1": "Each bookmark needs a descriptive title and the page it should open.",
|
||||||
|
"line2": "Use child bookmarks to build a hierarchy for chapters, sections, or subsections.",
|
||||||
|
"line3": "Import bookmarks from the selected PDF or from a JSON file to save time."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"heading": "Bookmark editor",
|
||||||
|
"description": "Add, nest, and reorder bookmarks to craft your PDF outline.",
|
||||||
|
"addTopLevel": "Add top-level bookmark",
|
||||||
|
"empty": {
|
||||||
|
"title": "No bookmarks yet",
|
||||||
|
"description": "Import existing bookmarks or start by adding your first entry.",
|
||||||
|
"action": "Add first bookmark"
|
||||||
|
},
|
||||||
|
"defaultTitle": "New bookmark",
|
||||||
|
"defaultChildTitle": "Child bookmark",
|
||||||
|
"defaultSiblingTitle": "New bookmark",
|
||||||
|
"untitled": "Untitled bookmark",
|
||||||
|
"childBadge": "Child",
|
||||||
|
"pagePreview": "Page {{page}}",
|
||||||
|
"field": {
|
||||||
|
"title": "Bookmark title",
|
||||||
|
"page": "Target page number"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"toggle": "Toggle children",
|
||||||
|
"addChild": "Add child bookmark",
|
||||||
|
"addSibling": "Add sibling bookmark",
|
||||||
|
"remove": "Remove bookmark"
|
||||||
|
},
|
||||||
|
"confirmRemove": "Remove this bookmark and all of its children?"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"loadedTitle": "Bookmarks extracted",
|
||||||
|
"loadedBody": "Existing bookmarks from the PDF were loaded into the editor.",
|
||||||
|
"noBookmarks": "No bookmarks were found in the selected PDF.",
|
||||||
|
"loadFailed": "Unable to extract bookmarks from the selected PDF.",
|
||||||
|
"imported": "Bookmarks imported",
|
||||||
|
"importedBody": "Your JSON outline replaced the current editor contents.",
|
||||||
|
"importedClipboard": "Clipboard data replaced the current bookmark list.",
|
||||||
|
"invalidJson": "Invalid JSON structure",
|
||||||
|
"invalidJsonBody": "Please provide a valid bookmark JSON file and try again.",
|
||||||
|
"exported": "JSON download ready",
|
||||||
|
"copied": "Copied to clipboard",
|
||||||
|
"copiedBody": "Bookmark JSON copied successfully.",
|
||||||
|
"copyFailed": "Copy failed"
|
||||||
|
},
|
||||||
|
"submit": "Apply table of contents",
|
||||||
|
"results": {
|
||||||
|
"title": "Updated PDF with bookmarks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"removePages": {
|
"removePages": {
|
||||||
"tags": "Remove pages,delete pages",
|
"tags": "Remove pages,delete pages",
|
||||||
"title": "Remove Pages",
|
"title": "Remove Pages",
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
import { BookmarkNode, createBookmarkNode } from '@app/utils/editTableOfContents';
|
||||||
|
|
||||||
|
interface BookmarkEditorProps {
|
||||||
|
bookmarks: BookmarkNode[];
|
||||||
|
onChange: (bookmarks: BookmarkNode[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTree = (
|
||||||
|
nodes: BookmarkNode[],
|
||||||
|
targetId: string,
|
||||||
|
updater: (bookmark: BookmarkNode) => BookmarkNode,
|
||||||
|
): BookmarkNode[] => {
|
||||||
|
return nodes.map(node => {
|
||||||
|
if (node.id === targetId) {
|
||||||
|
return updater(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children.length === 0) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedChildren = updateTree(node.children, targetId, updater);
|
||||||
|
if (updatedChildren !== node.children) {
|
||||||
|
return { ...node, children: updatedChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromTree = (nodes: BookmarkNode[], targetId: string): BookmarkNode[] => {
|
||||||
|
return nodes
|
||||||
|
.filter(node => node.id !== targetId)
|
||||||
|
.map(node => ({
|
||||||
|
...node,
|
||||||
|
children: removeFromTree(node.children, targetId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChildToTree = (
|
||||||
|
nodes: BookmarkNode[],
|
||||||
|
parentId: string,
|
||||||
|
child: BookmarkNode,
|
||||||
|
): { nodes: BookmarkNode[]; added: boolean } => {
|
||||||
|
let added = false;
|
||||||
|
const next = nodes.map(node => {
|
||||||
|
if (node.id === parentId) {
|
||||||
|
added = true;
|
||||||
|
return { ...node, expanded: true, children: [...node.children, child] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children.length === 0) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = addChildToTree(node.children, parentId, child);
|
||||||
|
if (result.added) {
|
||||||
|
added = true;
|
||||||
|
return { ...node, children: result.nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: added ? next : nodes, added };
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSiblingInTree = (
|
||||||
|
nodes: BookmarkNode[],
|
||||||
|
targetId: string,
|
||||||
|
sibling: BookmarkNode,
|
||||||
|
): { nodes: BookmarkNode[]; added: boolean } => {
|
||||||
|
let added = false;
|
||||||
|
const result: BookmarkNode[] = [];
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
if (!added && node.children.length > 0) {
|
||||||
|
const childResult = addSiblingInTree(node.children, targetId, sibling);
|
||||||
|
if (childResult.added) {
|
||||||
|
added = true;
|
||||||
|
currentNode = { ...node, children: childResult.nodes };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(currentNode);
|
||||||
|
|
||||||
|
if (!added && node.id === targetId) {
|
||||||
|
result.push(sibling);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: added ? result : nodes, added };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BookmarkEditor({ bookmarks, onChange, disabled }: BookmarkEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleAddTopLevel = () => {
|
||||||
|
const newBookmark = createBookmarkNode({ title: t('editTableOfContents.editor.defaultTitle', 'New bookmark') });
|
||||||
|
onChange([...bookmarks, newBookmark]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleChange = (id: string, value: string) => {
|
||||||
|
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, title: value })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (id: string, value: number | string) => {
|
||||||
|
const page = typeof value === 'number' ? value : parseInt(value, 10);
|
||||||
|
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, pageNumber: Number.isFinite(page) && page > 0 ? page : 1 })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (id: string) => {
|
||||||
|
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, expanded: !bookmark.expanded })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: string) => {
|
||||||
|
const confirmation = t(
|
||||||
|
'editTableOfContents.editor.confirmRemove',
|
||||||
|
'Remove this bookmark and all of its children?'
|
||||||
|
);
|
||||||
|
if (window.confirm(confirmation)) {
|
||||||
|
onChange(removeFromTree(bookmarks, id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddChild = (parentId: string) => {
|
||||||
|
const child = createBookmarkNode({ title: t('editTableOfContents.editor.defaultChildTitle', 'Child bookmark') });
|
||||||
|
const { nodes, added } = addChildToTree(bookmarks, parentId, child);
|
||||||
|
onChange(added ? nodes : [...bookmarks, child]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSibling = (targetId: string) => {
|
||||||
|
const sibling = createBookmarkNode({ title: t('editTableOfContents.editor.defaultSiblingTitle', 'New bookmark') });
|
||||||
|
const { nodes, added } = addSiblingInTree(bookmarks, targetId, sibling);
|
||||||
|
onChange(added ? nodes : [...bookmarks, sibling]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBookmark = (bookmark: BookmarkNode, level = 0) => {
|
||||||
|
const hasChildren = bookmark.children.length > 0;
|
||||||
|
const chevronIcon = bookmark.expanded ? 'expand-more-rounded' : 'chevron-right-rounded';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={bookmark.id}
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--border-default)',
|
||||||
|
background: level === 0 ? 'var(--bg-surface)' : 'var(--bg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Flex align="flex-start" justify="space-between" gap="md">
|
||||||
|
<Group gap="sm" align="flex-start">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => hasChildren && handleToggle(bookmark.id)}
|
||||||
|
disabled={disabled || !hasChildren}
|
||||||
|
aria-label={t('editTableOfContents.editor.actions.toggle', 'Toggle children')}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
>
|
||||||
|
<LocalIcon icon={chevronIcon} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<Text fw={600}>{bookmark.title || t('editTableOfContents.editor.untitled', 'Untitled bookmark')}</Text>
|
||||||
|
{level > 0 && (
|
||||||
|
<Badge size="xs" variant="light" color="blue">
|
||||||
|
{t('editTableOfContents.editor.childBadge', 'Child')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('editTableOfContents.editor.pagePreview', { page: bookmark.pageNumber })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Tooltip label={t('editTableOfContents.editor.actions.addChild', 'Add child bookmark')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="green"
|
||||||
|
onClick={() => handleAddChild(bookmark.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="subdirectory-arrow-right-rounded" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('editTableOfContents.editor.actions.addSibling', 'Add sibling bookmark')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
onClick={() => handleAddSibling(bookmark.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="add-rounded" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('editTableOfContents.editor.actions.remove', 'Remove bookmark')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleRemove(bookmark.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="delete-rounded" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{bookmark.expanded && (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
label={t('editTableOfContents.editor.field.title', 'Bookmark title')}
|
||||||
|
value={bookmark.title}
|
||||||
|
onChange={event => handleTitleChange(bookmark.id, event.currentTarget.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
size="sm"
|
||||||
|
label={t('editTableOfContents.editor.field.page', 'Target page number')}
|
||||||
|
min={1}
|
||||||
|
clampBehavior="strict"
|
||||||
|
value={bookmark.pageNumber}
|
||||||
|
onChange={value => handlePageChange(bookmark.id, value ?? 1)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bookmark.expanded && hasChildren && (
|
||||||
|
<Stack gap="sm" pl="lg" style={{ borderLeft: '1px solid var(--border-default)' }}>
|
||||||
|
{bookmark.children.map(child => (
|
||||||
|
<Fragment key={child.id}>{renderBookmark(child, level + 1)}</Fragment>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('editTableOfContents.editor.description', 'Add, nest, and reorder bookmarks to craft your PDF outline.')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<LocalIcon icon="bookmark-add-rounded" />}
|
||||||
|
onClick={handleAddTopLevel}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.editor.addTopLevel', 'Add top-level bookmark')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{bookmarks.length === 0 ? (
|
||||||
|
<Paper withBorder radius="md" ta="center" py="xl">
|
||||||
|
<Stack gap="xs" align="center" px="lg">
|
||||||
|
<LocalIcon icon="bookmark-add-rounded" style={{ fontSize: '2.25rem' }} />
|
||||||
|
<Text fw={600}>{t('editTableOfContents.editor.empty.title', 'No bookmarks yet')}</Text>
|
||||||
|
<Text size="sm" c="dimmed" maw={420}>
|
||||||
|
{t('editTableOfContents.editor.empty.description', 'Import existing bookmarks or start by adding your first entry.')}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<LocalIcon icon="add-rounded" />}
|
||||||
|
onClick={handleAddTopLevel}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.editor.empty.action', 'Add first bookmark')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{bookmarks.map(bookmark => renderBookmark(bookmark))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
FileButton,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||||
|
|
||||||
|
interface EditTableOfContentsSettingsProps {
|
||||||
|
bookmarks: BookmarkNode[];
|
||||||
|
replaceExisting: boolean;
|
||||||
|
onReplaceExistingChange: (value: boolean) => void;
|
||||||
|
onSelectFiles: () => void;
|
||||||
|
onLoadFromPdf: () => void;
|
||||||
|
onImportJson: (file: File) => void;
|
||||||
|
onImportClipboard: () => void;
|
||||||
|
onExportJson: () => void;
|
||||||
|
onExportClipboard: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
loadError?: string | null;
|
||||||
|
canReadClipboard: boolean;
|
||||||
|
canWriteClipboard: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
selectedFileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditTableOfContentsSettings({
|
||||||
|
bookmarks,
|
||||||
|
replaceExisting,
|
||||||
|
onReplaceExistingChange,
|
||||||
|
onSelectFiles,
|
||||||
|
onLoadFromPdf,
|
||||||
|
onImportJson,
|
||||||
|
onImportClipboard,
|
||||||
|
onExportJson,
|
||||||
|
onExportClipboard,
|
||||||
|
isLoading,
|
||||||
|
loadError,
|
||||||
|
canReadClipboard,
|
||||||
|
canWriteClipboard,
|
||||||
|
disabled,
|
||||||
|
selectedFileName,
|
||||||
|
}: EditTableOfContentsSettingsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const infoLines = useMemo(() => ([
|
||||||
|
t('editTableOfContents.info.line1', 'Each bookmark needs a descriptive title and the page it should open.'),
|
||||||
|
t('editTableOfContents.info.line2', 'Use child bookmarks to build a hierarchy for chapters, sections, or subsections.'),
|
||||||
|
t('editTableOfContents.info.line3', 'Import bookmarks from the selected PDF or from a JSON file to save time.'),
|
||||||
|
]), [t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" fw={500}>{t('editTableOfContents.actions.source', 'Load bookmarks')}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{selectedFileName
|
||||||
|
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
|
||||||
|
: t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<LocalIcon icon="folder-rounded" />}
|
||||||
|
onClick={onSelectFiles}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{selectedFileName
|
||||||
|
? t('editTableOfContents.workbench.changeFile', 'Change PDF')
|
||||||
|
: t('editTableOfContents.workbench.selectFile', 'Select PDF')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Tooltip label={!selectedFileName ? t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.') : ''} disabled={Boolean(selectedFileName)}>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<LocalIcon icon="picture-as-pdf-rounded" />}
|
||||||
|
onClick={onLoadFromPdf}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={disabled || !selectedFileName}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.actions.loadFromPdf', 'Load from PDF')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<FileButton
|
||||||
|
onChange={file => file && onImportJson(file)}
|
||||||
|
accept="application/json"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
variant="default"
|
||||||
|
leftSection={<LocalIcon icon="upload-rounded" />}
|
||||||
|
disabled={disabled}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.actions.importJson', 'Import JSON')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={canReadClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
|
||||||
|
disabled={canReadClipboard}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<LocalIcon icon="content-paste-rounded" />}
|
||||||
|
onClick={onImportClipboard}
|
||||||
|
disabled={disabled || !canReadClipboard}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.actions.importClipboard', 'Paste from clipboard')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<Alert color="red" radius="md" icon={<LocalIcon icon="error-outline-rounded" />}>
|
||||||
|
{loadError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" fw={500}>{t('editTableOfContents.actions.export', 'Export bookmarks')}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<LocalIcon icon="download-rounded" />}
|
||||||
|
onClick={onExportJson}
|
||||||
|
disabled={disabled || bookmarks.length === 0}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.actions.exportJson', 'Download JSON')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={canWriteClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
|
||||||
|
disabled={canWriteClipboard}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<LocalIcon icon="content-copy-rounded" />}
|
||||||
|
onClick={onExportClipboard}
|
||||||
|
disabled={disabled || bookmarks.length === 0 || !canWriteClipboard}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.actions.exportClipboard', 'Copy to clipboard')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={replaceExisting}
|
||||||
|
onChange={(event) => onReplaceExistingChange(event.currentTarget.checked)}
|
||||||
|
label={t('editTableOfContents.settings.replaceExisting', 'Replace existing bookmarks')}
|
||||||
|
description={t('editTableOfContents.settings.replaceExistingHint', 'When disabled, the new outline is appended after the current bookmarks.')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
{infoLines.map((line, index) => (
|
||||||
|
<Text key={index} size="sm" c="dimmed">
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||||
|
import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
|
||||||
|
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
|
||||||
|
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
|
||||||
|
|
||||||
|
export interface EditTableOfContentsWorkbenchViewData {
|
||||||
|
bookmarks: BookmarkNode[];
|
||||||
|
selectedFileName?: string;
|
||||||
|
disabled: boolean;
|
||||||
|
files: File[];
|
||||||
|
thumbnails: (string | undefined)[];
|
||||||
|
downloadUrl: string | null;
|
||||||
|
downloadFilename: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
isGeneratingThumbnails: boolean;
|
||||||
|
isExecuteDisabled: boolean;
|
||||||
|
isExecuting: boolean;
|
||||||
|
onClearError: () => void;
|
||||||
|
onBookmarksChange: (bookmarks: BookmarkNode[]) => void;
|
||||||
|
onExecute: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onFileClick: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditTableOfContentsWorkbenchViewProps {
|
||||||
|
data: EditTableOfContentsWorkbenchViewData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Box p="xl">
|
||||||
|
<Card withBorder radius="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text fw={600}>{t('editTableOfContents.workbench.empty.title', 'Open the tool to start editing')}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('editTableOfContents.workbench.empty.description', 'Select the Edit Table of Contents tool to load its workspace.')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
bookmarks,
|
||||||
|
selectedFileName,
|
||||||
|
disabled,
|
||||||
|
files,
|
||||||
|
thumbnails,
|
||||||
|
downloadUrl,
|
||||||
|
downloadFilename,
|
||||||
|
errorMessage,
|
||||||
|
isGeneratingThumbnails,
|
||||||
|
isExecuteDisabled,
|
||||||
|
isExecuting,
|
||||||
|
onClearError,
|
||||||
|
onBookmarksChange,
|
||||||
|
onExecute,
|
||||||
|
onUndo,
|
||||||
|
onFileClick,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const previewFiles = useMemo(
|
||||||
|
() =>
|
||||||
|
files?.map((file, index) => ({
|
||||||
|
file,
|
||||||
|
thumbnail: thumbnails[index],
|
||||||
|
})) ?? [],
|
||||||
|
[files, thumbnails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showResults = Boolean(
|
||||||
|
previewFiles.length > 0 || downloadUrl || errorMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p="lg"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--bg-raised)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="xl" maw={1200} mx="auto">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
{t('home.editTableOfContents.title', 'Edit Table of Contents')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('editTableOfContents.workbench.subtitle', 'Import bookmarks, build hierarchies, and apply the outline without cramped side panels.')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-surface)',
|
||||||
|
borderColor: 'var(--border-default)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{selectedFileName
|
||||||
|
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
|
||||||
|
: t('editTableOfContents.workbench.filePrompt', 'Select a PDF from your library or upload a new one to begin.')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<BookmarkEditor bookmarks={bookmarks} onChange={onBookmarksChange} disabled={disabled} />
|
||||||
|
<Divider />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
leftSection={<LocalIcon icon="menu-book-rounded" />}
|
||||||
|
color="blue"
|
||||||
|
onClick={onExecute}
|
||||||
|
disabled={isExecuteDisabled}
|
||||||
|
loading={isExecuting}
|
||||||
|
>
|
||||||
|
{t('editTableOfContents.submit', 'Apply table of contents')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showResults && (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-surface)',
|
||||||
|
borderColor: 'var(--border-default)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text fw={600}>{t('editTableOfContents.results.title', 'Updated PDF with bookmarks')}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('editTableOfContents.results.subtitle', 'Download the processed file or undo the operation below.')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ErrorNotification error={errorMessage} onClose={onClearError} />
|
||||||
|
|
||||||
|
{previewFiles.length > 0 && (
|
||||||
|
<ResultsPreview
|
||||||
|
files={previewFiles}
|
||||||
|
onFileClick={onFileClick}
|
||||||
|
isGeneratingThumbnails={isGeneratingThumbnails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
{downloadUrl && (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={downloadUrl}
|
||||||
|
download={downloadFilename ?? undefined}
|
||||||
|
leftSection={<LocalIcon icon='download-rounded' />}
|
||||||
|
>
|
||||||
|
{t('download', 'Download')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<LocalIcon icon="rotate-left" />}
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={isExecuting}
|
||||||
|
>
|
||||||
|
{t('undo', 'Undo')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditTableOfContentsWorkbenchView;
|
||||||
@@ -31,6 +31,7 @@ import AddWatermark from "@app/tools/AddWatermark";
|
|||||||
import AddStamp from "@app/tools/AddStamp";
|
import AddStamp from "@app/tools/AddStamp";
|
||||||
import AddAttachments from "@app/tools/AddAttachments";
|
import AddAttachments from "@app/tools/AddAttachments";
|
||||||
import Merge from '@app/tools/Merge';
|
import Merge from '@app/tools/Merge';
|
||||||
|
import EditTableOfContents from '@app/tools/EditTableOfContents';
|
||||||
import Repair from "@app/tools/Repair";
|
import Repair from "@app/tools/Repair";
|
||||||
import AutoRename from "@app/tools/AutoRename";
|
import AutoRename from "@app/tools/AutoRename";
|
||||||
import SingleLargePage from "@app/tools/SingleLargePage";
|
import SingleLargePage from "@app/tools/SingleLargePage";
|
||||||
@@ -63,6 +64,7 @@ import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermiss
|
|||||||
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
|
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
|
||||||
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||||
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
|
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
|
||||||
|
import { editTableOfContentsOperationConfig } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
|
||||||
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
|
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
|
||||||
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
|
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
|
||||||
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
|
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
|
||||||
@@ -345,6 +347,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
automationSettings: ChangeMetadataSingleStep,
|
automationSettings: ChangeMetadataSingleStep,
|
||||||
synonyms: getSynonyms(t, "changeMetadata")
|
synonyms: getSynonyms(t, "changeMetadata")
|
||||||
},
|
},
|
||||||
|
editTableOfContents: {
|
||||||
|
icon: <LocalIcon icon="toc-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
|
||||||
|
component: EditTableOfContents,
|
||||||
|
description: t(
|
||||||
|
"home.editTableOfContents.desc",
|
||||||
|
"Add or edit bookmarks and table of contents in PDF documents"
|
||||||
|
),
|
||||||
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
|
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||||
|
maxFiles: 1,
|
||||||
|
endpoints: ["edit-table-of-contents"],
|
||||||
|
operationConfig: editTableOfContentsOperationConfig,
|
||||||
|
automationSettings: null,
|
||||||
|
supportsAutomate: false,
|
||||||
|
synonyms: getSynonyms(t, "editTableOfContents"),
|
||||||
|
},
|
||||||
// Page Formatting
|
// Page Formatting
|
||||||
|
|
||||||
crop: {
|
crop: {
|
||||||
@@ -689,16 +708,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
synonyms: getSynonyms(t, "addImage"),
|
synonyms: getSynonyms(t, "addImage"),
|
||||||
automationSettings: null
|
automationSettings: null
|
||||||
},
|
},
|
||||||
editTableOfContents: {
|
|
||||||
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
|
|
||||||
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
|
|
||||||
component: null,
|
|
||||||
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
|
|
||||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
|
||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
|
||||||
synonyms: getSynonyms(t, "editTableOfContents"),
|
|
||||||
automationSettings: null
|
|
||||||
},
|
|
||||||
scannerEffect: {
|
scannerEffect: {
|
||||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.scannerEffect.title", "Scanner Effect"),
|
name: t("home.scannerEffect.title", "Scanner Effect"),
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ToolType, type ToolOperationConfig, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
|
||||||
|
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
|
||||||
|
import { EditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters';
|
||||||
|
import { serializeBookmarkNodes } from '@app/utils/editTableOfContents';
|
||||||
|
|
||||||
|
const buildFormData = (parameters: EditTableOfContentsParameters, file: File): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileInput', file);
|
||||||
|
formData.append('replaceExisting', String(parameters.replaceExisting));
|
||||||
|
formData.append('bookmarkData', JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)));
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editTableOfContentsOperationConfig: ToolOperationConfig<EditTableOfContentsParameters> = {
|
||||||
|
toolType: ToolType.singleFile,
|
||||||
|
operationType: 'editTableOfContents',
|
||||||
|
endpoint: '/api/v1/general/edit-table-of-contents',
|
||||||
|
buildFormData,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditTableOfContentsOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useToolOperation<EditTableOfContentsParameters>({
|
||||||
|
...editTableOfContentsOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(
|
||||||
|
t('editTableOfContents.error.failed', 'Failed to update the table of contents')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
||||||
|
import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||||
|
|
||||||
|
export interface EditTableOfContentsParameters {
|
||||||
|
replaceExisting: boolean;
|
||||||
|
bookmarks: BookmarkNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditTableOfContentsParametersHook extends BaseParametersHook<EditTableOfContentsParameters> {
|
||||||
|
setBookmarks: (bookmarks: BookmarkNode[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultParameters: EditTableOfContentsParameters = {
|
||||||
|
replaceExisting: true,
|
||||||
|
bookmarks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditTableOfContentsParameters = (): EditTableOfContentsParametersHook => {
|
||||||
|
const base = useBaseParameters<EditTableOfContentsParameters>({
|
||||||
|
defaultParameters,
|
||||||
|
endpointName: 'edit-table-of-contents',
|
||||||
|
});
|
||||||
|
|
||||||
|
const setBookmarks = useCallback((bookmarks: BookmarkNode[]) => {
|
||||||
|
base.setParameters(prev => ({
|
||||||
|
...prev,
|
||||||
|
bookmarks,
|
||||||
|
}));
|
||||||
|
}, [base.setParameters]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
setBookmarks,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface BookmarkPayload {
|
||||||
|
title: string;
|
||||||
|
pageNumber: number;
|
||||||
|
children?: BookmarkPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookmarkNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
pageNumber: number;
|
||||||
|
children: BookmarkNode[];
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBookmarkId = () => {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBookmarkNode = (bookmark?: Partial<BookmarkNode>): BookmarkNode => ({
|
||||||
|
id: bookmark?.id ?? createBookmarkId(),
|
||||||
|
title: bookmark?.title ?? '',
|
||||||
|
pageNumber: bookmark?.pageNumber ?? 1,
|
||||||
|
children: bookmark?.children ? bookmark.children.map(child => createBookmarkNode(child)) : [],
|
||||||
|
expanded: bookmark?.expanded ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hydrateBookmarkPayload = (payload: BookmarkPayload[] = []): BookmarkNode[] => {
|
||||||
|
return payload.map(item => ({
|
||||||
|
id: createBookmarkId(),
|
||||||
|
title: item.title ?? '',
|
||||||
|
pageNumber: typeof item.pageNumber === 'number' && item.pageNumber > 0 ? item.pageNumber : 1,
|
||||||
|
expanded: true,
|
||||||
|
children: item.children ? hydrateBookmarkPayload(item.children) : [],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeBookmarkNodes = (bookmarks: BookmarkNode[]): BookmarkPayload[] => {
|
||||||
|
return bookmarks.map(bookmark => ({
|
||||||
|
title: bookmark.title,
|
||||||
|
pageNumber: bookmark.pageNumber,
|
||||||
|
children: serializeBookmarkNodes(bookmark.children),
|
||||||
|
}));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user