mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
Squashed commit of the following:
commitf1901a2e56Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 27 18:23:27 2025 +0000 revert lint commit09b0fbefcdAuthor: Reece <reece@stirlingpdf.com> Date: Mon Oct 27 15:47:38 2025 +0000 Hide file names in posthog commit3497ccd7bdAuthor: Reece <reece@stirlingpdf.com> Date: Mon Oct 27 12:45:31 2025 +0000 remove page break settings modal commit5e27dc88f8Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 27 12:37:00 2025 +0000 retain interleaving commitb276eb5b68Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 27 11:32:31 2025 +0000 Lint commitaec1f97ff8Author: Reece <reece@stirlingpdf.com> Date: Sat Oct 25 14:19:32 2025 +0100 - commitfbe2dc2958Author: Reece <reece@stirlingpdf.com> Date: Sat Oct 25 13:06:10 2025 +0100 Fixed file reordering placeholder commitaaae81c68eAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 15:57:30 2025 +0100 - commit3aa77819f2Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 15:54:30 2025 +0100 - commit28dab07870Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 15:51:37 2025 +0100 - commited6199de61Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 15:51:29 2025 +0100 lint and revert onboarding commit4d59ebfb2aAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 15:27:44 2025 +0100 fixed drag and drop when some files aren't selected in context commitea4f37cccfAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 15:06:21 2025 +0100 Merge history change commitc25131ae9bAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 14:48:14 2025 +0100 lint commit25df9410cdMerge:494f92421848ff9688Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 14:33:27 2025 +0100 Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/selected-pageeditor commit494f92421fAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 13:28:50 2025 +0100 Enhance drag-and-drop functionality with new drop hint resolution and target index calculation; refactor file color mapping in PageEditor and implement dropdown state management for improved file handling. commiteef5dce849Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 24 11:33:31 2025 +0100 Drag and drop improvements basic box select commitddefe81082Author: Reece <reece@stirlingpdf.com> Date: Thu Oct 23 20:46:58 2025 +0100 Enhance DragDropGrid and PageEditor with improved undo manager functionality and scroll handling during drag operations commitbe037b727fAuthor: Reece <reece@stirlingpdf.com> Date: Thu Oct 23 18:15:37 2025 +0100 File reorder logic commit7a56f0504eAuthor: Reece <reece@stirlingpdf.com> Date: Tue Oct 21 17:35:55 2025 +0100 Refactor file handling to support StirlingFileStubs and improve drag-and-drop functionality commitf7c9855489Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 21:45:00 2025 +0100 glow scaling commit36a358f907Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 21:25:44 2025 +0100 Visual tweaks commit0bcb1810d6Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 21:08:18 2025 +0100 tweak commitaee535214dAuthor: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 20:55:45 2025 +0100 Pretty lights commit6d3154a7aeAuthor: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 20:44:48 2025 +0100 Update top bar controls visually commit658ce2dab9Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 18:45:14 2025 +0100 add file commit15df5cf168Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 18:05:55 2025 +0100 - commit23d7f38100Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 17:24:16 2025 +0100 lint commit472fc2939eAuthor: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 15:56:54 2025 +0100 lint 2 commita21047e8b0Merge:8ee03fa1c3e23dc59bAuthor: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon Oct 20 15:52:23 2025 +0100 Merge branch 'V2' into feature/v2/selected-pageeditor commit8ee03fa1c6Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 15:50:14 2025 +0100 Lint commita22913e1e4Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 14:16:41 2025 +0100 page editor fixes post merge commitb3c0c69a7cMerge:2289080f93e6236d95Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 20 13:42:08 2025 +0100 Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/selected-pageeditor commit2289080f9cAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 17 16:55:29 2025 +0100 remove buttons commita5ec62fa08Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 17 15:24:05 2025 +0100 Performance improvements commite7f7b7e201Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 17 14:15:21 2025 +0100 improved commit74e8388bceAuthor: Reece <reece@stirlingpdf.com> Date: Wed Oct 15 21:33:54 2025 +0100 Working mostly commite7c6db082cAuthor: Reece <reece@stirlingpdf.com> Date: Wed Oct 15 16:31:30 2025 +0100 Rejig arrays commit05a7161412Author: Reece <reece@stirlingpdf.com> Date: Wed Oct 15 00:01:30 2025 +0100 Structural tweaks commit39267e795cAuthor: Reece <reece@stirlingpdf.com> Date: Tue Oct 14 12:41:50 2025 +0100 Reworked page editor - dirty commit commit6acce968a5Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 19:32:41 2025 +0100 fix 2 commit0722ecc6c4Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 19:27:15 2025 +0100 fix commit3597a8b7bdAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 19:16:04 2025 +0100 Initial set up commitc260394b95Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 17:15:07 2025 +0100 Cleanup commit93fcfb280aAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 17:09:36 2025 +0100 Remove logs tweak visuals, use fit text component commit69cb8e7aecAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 16:54:05 2025 +0100 Fix signwith tab based system commit8e8e06628eAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 15:57:41 2025 +0100 Nav based file select commit5d3710260fAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 13:37:52 2025 +0100 Lint commitad8789d82aAuthor: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 13:35:18 2025 +0100 remove file that came from nowhere commit749966a197Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 13:30:15 2025 +0100 Remove mantine theme commitd9e429aa3aMerge:ad0b6cf2db695e3900Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 13:00:56 2025 +0100 Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/V2/ViewerTabs commitad0b6cf2d6Author: Reece <reece@stirlingpdf.com> Date: Fri Oct 10 12:55:03 2025 +0100 Viewer tabs, embed update and layout fixes commitb63f2c16a2Author: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 15:12:39 2025 +0100 Remove unused legacy text signing Linting errors commitedcc788d1aMerge:5b47ab5bbfdba336c0Author: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 15:02:39 2025 +0100 Merge branch 'feature/v2/improve-sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/improve-sign commit5b47ab5bbfAuthor: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 15:02:33 2025 +0100 Remove debug logs commitfdba336c05Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed Oct 8 14:57:29 2025 +0100 Update frontend/src/components/annotation/shared/DrawingCanvas.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit5db6b85fb9Merge:70d941a4013e88943bAuthor: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 14:56:10 2025 +0100 Merge branch 'feature/v2/improve-sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/improve-sign commit70d941a400Author: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 14:55:43 2025 +0100 translations commit13e88943b7Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed Oct 8 14:55:04 2025 +0100 Update frontend/src/components/tools/sign/SignSettings.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit339e5cfb65Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed Oct 8 14:54:31 2025 +0100 Update frontend/src/contexts/ViewerContext.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit10944d9d57Author: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 14:45:39 2025 +0100 Remove debug logging commit0c9f460fb6Author: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 14:02:43 2025 +0100 Remove arbitrary timers commitfa6e01b46eAuthor: Reece <reece@stirlingpdf.com> Date: Wed Oct 8 12:26:34 2025 +0100 Clean up commit23f85d7267Author: Reece <reece@stirlingpdf.com> Date: Tue Oct 7 22:40:58 2025 +0100 tweaks commitf6290c0238Author: Reece <reece@stirlingpdf.com> Date: Tue Oct 7 21:52:40 2025 +0100 - Refactored signature saving process commit991be9ffa2Author: Reece <reece@stirlingpdf.com> Date: Tue Oct 7 21:38:07 2025 +0100 Add text color and font size options to signature settings and API commit07bf79f3eeAuthor: Reece <reece@stirlingpdf.com> Date: Tue Oct 7 14:54:14 2025 +0100 Improved canvas mode with signaturepad.js commit3a0acd0a21Author: Reece <reece@stirlingpdf.com> Date: Tue Oct 7 12:56:12 2025 +0100 Single canvas commitfff637286fAuthor: Reece <reece@stirlingpdf.com> Date: Tue Oct 7 12:08:32 2025 +0100 Clean up annotation layer and signature API - Remove duplicate imports in LocalEmbedPDF - Remove duplicate setAnnotations state declaration - Rename enableSignature prop to enableAnnotations for consistency - Remove debug console.log statements from SignatureAPIBridge - Remove async image preloading wrapper (was debugging code) - Clean up formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> commit8f94c8f57eMerge:708a296f82a29bda34Author: Reece <reece@stirlingpdf.com> Date: Mon Oct 6 22:25:30 2025 +0100 Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/improve-sign commit708a296f8dAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 19:14:40 2025 +0100 Auto update canvas signature commitb486d1270eAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 19:03:24 2025 +0100 Fix flicker on apply commit80faf0bc1eAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 18:55:09 2025 +0100 - commit6555a9554aAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 18:53:16 2025 +0100 Fix even more linting errors (Thanks James) commitfdee719c89Merge:1be48c276fd9fb9b97Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 18:14:42 2025 +0100 Merge branch 'feature/v2/sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/sign commit1be48c276bAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 18:12:13 2025 +0100 fix text infinite loop commit2b6b7a8e1dAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 18:04:01 2025 +0100 better error handling and killing logs commitfd9fb9b972Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri Sep 26 17:58:52 2025 +0100 Update frontend/src/hooks/tools/sign/useSignParameters.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commitd8d6197008Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 17:48:41 2025 +0100 fix page count issue commit1edd133e09Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 17:31:44 2025 +0100 license checker use commonJS commit8685bf2a7cAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 17:26:19 2025 +0100 gap commit36475069deAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 17:23:16 2025 +0100 lint fix commit3aa8572c9eAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 17:16:17 2025 +0100 Fix suggestions commit2e2d8477b9Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 17:01:06 2025 +0100 Clean up commit90880eddf9Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri Sep 26 16:51:19 2025 +0100 Update docker/frontend/nginx.conf Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit31fd6886dcMerge:3fdbf425babc0988fdAuthor: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri Sep 26 16:40:34 2025 +0100 Merge branch 'V2' into feature/v2/sign commit3fdbf425b4Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 16:39:38 2025 +0100 Fix lintineg errors commit50e60d4972Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 16:27:52 2025 +0100 Simple export block commita22330ebf4Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 16:09:20 2025 +0100 Only flatten current annotations commit172f622c5fMerge:cfd00b2c7d82b958d9Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 15:10:54 2025 +0100 Merge branch 'feature/v2/sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/sign commitcfd00b2c71Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 15:10:47 2025 +0100 Render signature to pdf commitd82b958d9fMerge:c94ee388f0bdc6466cAuthor: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri Sep 26 12:53:54 2025 +0100 Merge branch 'V2' into feature/v2/sign commitc94ee388fcAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 12:47:32 2025 +0100 Restructure and bug fix commitaa5333dcd9Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 03:23:59 2025 +0100 Change to button based placement to avoid performance issue on canvas commita8265efff4Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 03:19:05 2025 +0100 Improved performance commitb9b425aba0Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 02:18:47 2025 +0100 Fix undo/redo commit51caad636cAuthor: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 01:51:17 2025 +0100 Reduce logs commit023fd43b72Author: Reece <reece@stirlingpdf.com> Date: Fri Sep 26 01:49:33 2025 +0100 Save file commita8a0808274Author: Reece <reece@stirlingpdf.com> Date: Thu Sep 25 09:46:20 2025 +0100 history tweaks commit3d2607f72aAuthor: Reece <reece@stirlingpdf.com> Date: Wed Sep 24 19:01:36 2025 +0100 fixes commitf9542a9257Merge:a12e45757963787316Author: Reece <reece@stirlingpdf.com> Date: Wed Sep 24 18:35:16 2025 +0100 Merge branch 'feature/v2/exportpdf' into feature/v2/sign commit963787316aAuthor: Reece <reece@stirlingpdf.com> Date: Wed Sep 24 17:42:58 2025 +0100 Export with embedpdf commita12e457577Author: Reece Browne <reecebrowne1995@gmail.com> Date: Wed Sep 24 14:58:10 2025 +0100 Add undo/redo functionality and refactor signature settings UI - Introduced HistoryAPIBridge for managing undo/redo actions. - Updated SignSettings component to include undo/redo buttons. - Refactored signature type selection to use Tabs for better UI. - Enhanced SignatureAPIBridge to store image data for annotations. - Integrated history management into SignatureContext for state handling. commitbac61c7e9eAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Tue Sep 23 18:16:21 2025 +0100 Delete signature commitfc2f34ee15Author: Reece Browne <reecebrowne1995@gmail.com> Date: Tue Sep 23 17:18:39 2025 +0100 fix add image commitd9798badaeAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Tue Sep 23 14:06:41 2025 +0100 Fix sidebar refresh. Updated UI commitefc0c1aab3Author: Reece Browne <reecebrowne1995@gmail.com> Date: Tue Sep 23 12:24:58 2025 +0100 text and improved drawing commit10672403c9Author: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 22 14:14:35 2025 +0100 Colours on document draw + translations commit32fed96aa7Author: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 22 14:03:49 2025 +0100 Canvas and dosument draw split, drawing improvements commita70472b172Author: Reece Browne <reecebrowne1995@gmail.com> Date: Sat Sep 20 01:59:04 2025 +0100 Initial set up commit3b87ca0c3cMerge:0e1da982b6172351eeAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 19 11:38:58 2025 +0100 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf commit0e1da982b6Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 19 11:38:53 2025 +0100 Fix vite commit6172351eedMerge:1174b6a4dae7be50ecAuthor: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri Sep 19 11:23:28 2025 +0100 Merge branch 'V2' into feature/v2/embed-pdf commit1174b6a4daMerge:a970c44d021a2433ddAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 19 11:16:22 2025 +0100 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf commita970c44d03Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 19 11:14:58 2025 +0100 improvements commitb574cef54aAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 19 10:48:29 2025 +0100 improvements commit21a2433dd8Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu Sep 18 13:14:44 2025 +0100 Remove marginTop style from Workbench component commit07cc250176Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu Sep 18 13:12:58 2025 +0100 Remove comment regarding EmbedPDF import Removed comment about dynamic import of EmbedPDF. commitdc71b3007bAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 18 12:32:42 2025 +0100 clean up commit1598057ed0Author: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 18 08:44:57 2025 +0100 Tweaks commit312fc2d615Author: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 18 02:14:31 2025 +0100 Clean up commit72375d89d1Merge:a990ecc027ff1c66d0Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu Sep 18 01:53:59 2025 +0100 Merge branch 'V2' into feature/v2/embed-pdf commita990ecc02aMerge:da6ecc661b51c2e42aAuthor: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu Sep 18 01:53:47 2025 +0100 Merge branch 'V2' into feature/v2/embed-pdf commitda6ecc6619Author: Reece Browne <reecebrowne1995@gmail.com> Date: Wed Sep 17 14:35:44 2025 +0100 Fix scroll page identification commitdac176f0c6Author: Reece Browne <reecebrowne1995@gmail.com> Date: Wed Sep 17 12:07:44 2025 +0100 Fix colours commit41e5a7fbd6Author: Reece Browne <reecebrowne1995@gmail.com> Date: Wed Sep 17 12:00:20 2025 +0100 Restructure to avoid global variables fix zoom commitb81ed9ec2eMerge:9b5c50db081c5d8ff4Author: Reece Browne <reecebrowne1995@gmail.com> Date: Tue Sep 16 19:37:50 2025 +0100 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf commit9b5c50db07Author: Reece Browne <reecebrowne1995@gmail.com> Date: Tue Sep 16 19:36:36 2025 +0100 Improved Structure with context at root commit81c5d8ff46Author: James Brunton <james@stirlingpdf.com> Date: Tue Sep 16 16:06:40 2025 +0100 Potential fix for mime type issues commita67f5199d3Author: James Brunton <james@stirlingpdf.com> Date: Tue Sep 16 16:06:27 2025 +0100 Improvements for scroll gestures commit3755bfde34Author: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 18:20:11 2025 +0100 Set zoom to 140% commit2834eec3beMerge:19d7111cad89e1b5b1Author: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 17:31:06 2025 +0100 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf commitd89e1b5b1eMerge:5d7fb638aa57373b96Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon Sep 15 17:27:51 2025 +0100 Merge branch 'V2' into feature/v2/embed-pdf commit19d7111cabAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 17:27:22 2025 +0100 Remove unused code commitca9d7ef465Author: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 17:03:52 2025 +0100 Remove unused code commitfad4f84c9cAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 16:53:41 2025 +0100 translations commit35863ac610Author: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 16:53:32 2025 +0100 remove select mode commitc17dd25069Author: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 16:05:19 2025 +0100 Rotate commit5d7fb638afMerge:2fb4710dd7dad484aaAuthor: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon Sep 15 15:31:45 2025 +0100 Merge branch 'V2' into feature/v2/embed-pdf commit2fb4710dd7Merge:85a74c1d4cfdb6eaa1Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon Sep 15 13:34:00 2025 +0100 Merge branch 'V2' into feature/v2/embed-pdf commit85a74c1d46Merge:21a93d6ca9599bca8aAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 13:33:45 2025 +0100 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf commit21a93d6cacAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Mon Sep 15 13:33:39 2025 +0100 Context based right rail controls for viewer commit9599bca8a9Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon Sep 15 12:37:07 2025 +0100 Update frontend/src/components/viewer/ThumbnailSidebar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit1709ca9049Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 12 16:38:29 2025 +0100 Rems commit18e4e03220Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 12 16:26:05 2025 +0100 rename APIBridge commit9901771572Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 12 16:19:07 2025 +0100 improve search commit514956570cAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 12 15:06:06 2025 +0100 pan state improvements commit423617db52Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 12 14:21:31 2025 +0100 thumbnail sidebar commit143f0c5031Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 12 01:56:51 2025 +0100 search pdf commit368e9801a1Author: Reece Browne <reecebrowne1995@gmail.com> Date: Fri Sep 12 00:35:27 2025 +0100 Zoom with wheel and +/- commitafc9ca5858Author: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 11 23:52:38 2025 +0100 spread/multipage commit8815575124Author: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 11 22:51:10 2025 +0100 pan commitfb9b01f53bAuthor: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 11 20:07:43 2025 +0100 improved scaling and fix grey void commit93607937f6Author: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 11 19:38:04 2025 +0100 selection also commit687ab39286Author: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 11 19:36:44 2025 +0100 Text selection commit83a3222cf6Author: Reece Browne <reecebrowne1995@gmail.com> Date: Thu Sep 11 19:08:44 2025 +0100 Set up
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '@app/types/workbench';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { PageEditorProvider } from '@app/contexts/PageEditorContext';
|
||||
import { isBaseWorkbench } from '@app/types/workbench';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/layout/Workbench.css';
|
||||
|
||||
@@ -24,11 +26,13 @@ export default function Workbench() {
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { state, selectors } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Create stable reference for activeFiles based on file IDs
|
||||
const activeFiles = useMemo(() => selectors.getFiles(), [state.files.ids]);
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
@@ -72,31 +76,28 @@ export default function Workbench() {
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (activeFiles.length === 0) {
|
||||
return (
|
||||
<LandingPage
|
||||
/>
|
||||
);
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
switch (currentView) {
|
||||
case "fileEditor":
|
||||
case 'fileEditor':
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ['pdf']}
|
||||
{...(!selectedToolId && {
|
||||
onOpenPageEditor: () => {
|
||||
setCurrentView("pageEditor");
|
||||
setCurrentView('pageEditor');
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
addFiles(filesToMerge);
|
||||
setCurrentView("viewer");
|
||||
}
|
||||
setCurrentView('viewer');
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
case "viewer":
|
||||
case 'viewer':
|
||||
return (
|
||||
<Viewer
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
@@ -108,12 +109,10 @@ export default function Workbench() {
|
||||
/>
|
||||
);
|
||||
|
||||
case "pageEditor":
|
||||
case 'pageEditor':
|
||||
return (
|
||||
<>
|
||||
<PageEditor
|
||||
onFunctionsReady={setPageEditorFunctions}
|
||||
/>
|
||||
<PageEditor onFunctionsReady={setPageEditorFunctions} />
|
||||
{pageEditorFunctions && (
|
||||
<PageEditorControls
|
||||
onClosePdf={pageEditorFunctions.closePdf}
|
||||
@@ -141,7 +140,9 @@ export default function Workbench() {
|
||||
|
||||
default:
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
const customView = customWorkbenchViews.find(
|
||||
(view) => view.workbenchId === currentView && view.data != null,
|
||||
);
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
@@ -152,45 +153,47 @@ export default function Workbench() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
? {} // No background color in rainbow mode
|
||||
: { backgroundColor: 'var(--bg-background)' }
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map(f => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={setActiveFileIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<PageEditorProvider>
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
}}
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
? {}
|
||||
: { backgroundColor: 'var(--bg-background)' }
|
||||
}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map((f) => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={setActiveFileIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Footer analyticsEnabled={config?.enableAnalytics === true} />
|
||||
</Box>
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : activeFiles.length > 0 ? '3.5rem' : '0',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
<Footer analyticsEnabled={config?.enableAnalytics === true} />
|
||||
</Box>
|
||||
</PageEditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,386 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
splitAfter?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: string[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||
boxSelectedIds: string[],
|
||||
clearBoxSelection: () => void,
|
||||
getBoxSelection: () => string[],
|
||||
activeId: string | null,
|
||||
activeDragIds: string[],
|
||||
justMoved: boolean,
|
||||
isOver: boolean,
|
||||
dragHandleProps?: any,
|
||||
zoomLevel?: number,
|
||||
) => React.ReactNode;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
zoomLevel?: number;
|
||||
}
|
||||
|
||||
type DropSide = 'left' | 'right' | null;
|
||||
|
||||
interface DropHint {
|
||||
hoveredId: string | null;
|
||||
dropSide: DropSide;
|
||||
}
|
||||
|
||||
function resolveDropHint(
|
||||
activeId: string | null,
|
||||
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
): DropHint {
|
||||
if (!activeId) {
|
||||
return { hoveredId: null, dropSide: null };
|
||||
}
|
||||
|
||||
const rows = new Map<number, Array<{ id: string; rect: DOMRect }>>();
|
||||
|
||||
itemRefs.current.forEach((element, itemId) => {
|
||||
if (!element || itemId === activeId) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const rowCenter = rect.top + rect.height / 2;
|
||||
|
||||
let row = rows.get(rowCenter);
|
||||
if (!row) {
|
||||
row = [];
|
||||
rows.set(rowCenter, row);
|
||||
}
|
||||
row.push({ id: itemId, rect });
|
||||
});
|
||||
|
||||
let hoveredId: string | null = null;
|
||||
let dropSide: DropSide = null;
|
||||
|
||||
let closestRowY = 0;
|
||||
let closestRowDistance = Infinity;
|
||||
|
||||
rows.forEach((_items, rowY) => {
|
||||
const distance = Math.abs(cursorY - rowY);
|
||||
if (distance < closestRowDistance) {
|
||||
closestRowDistance = distance;
|
||||
closestRowY = rowY;
|
||||
}
|
||||
});
|
||||
|
||||
const closestRow = rows.get(closestRowY);
|
||||
if (!closestRow || closestRow.length === 0) {
|
||||
return { hoveredId: null, dropSide: null };
|
||||
}
|
||||
|
||||
let closestDistance = Infinity;
|
||||
closestRow.forEach(({ id, rect }) => {
|
||||
const distanceToLeft = Math.abs(cursorX - rect.left);
|
||||
const distanceToRight = Math.abs(cursorX - rect.right);
|
||||
|
||||
if (distanceToLeft < closestDistance) {
|
||||
closestDistance = distanceToLeft;
|
||||
hoveredId = id;
|
||||
dropSide = 'left';
|
||||
}
|
||||
if (distanceToRight < closestDistance) {
|
||||
closestDistance = distanceToRight;
|
||||
hoveredId = id;
|
||||
dropSide = 'right';
|
||||
}
|
||||
});
|
||||
|
||||
return { hoveredId, dropSide };
|
||||
}
|
||||
|
||||
function resolveTargetIndex<T extends DragDropItem>(
|
||||
hoveredId: string | null,
|
||||
dropSide: DropSide,
|
||||
filteredItems: T[],
|
||||
filteredToOriginalIndex: number[],
|
||||
originalItemsLength: number,
|
||||
fallbackIndex: number | null,
|
||||
): number | null {
|
||||
const convertFilteredIndexToOriginal = (filteredIndex: number): number => {
|
||||
if (filteredToOriginalIndex.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (filteredIndex <= 0) {
|
||||
return filteredToOriginalIndex[0];
|
||||
}
|
||||
|
||||
if (filteredIndex >= filteredToOriginalIndex.length) {
|
||||
return originalItemsLength;
|
||||
}
|
||||
|
||||
return filteredToOriginalIndex[filteredIndex];
|
||||
};
|
||||
|
||||
if (hoveredId) {
|
||||
const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId);
|
||||
if (filteredIndex !== -1) {
|
||||
const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0);
|
||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackIndex !== null && fallbackIndex !== undefined) {
|
||||
const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0);
|
||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DraggableItemProps<T extends DragDropItem> {
|
||||
item: T;
|
||||
index: number;
|
||||
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
boxSelectedPageIds: string[];
|
||||
clearBoxSelection: () => void;
|
||||
getBoxSelection: () => string[];
|
||||
activeId: string | null;
|
||||
activeDragIds: string[];
|
||||
justMoved: boolean;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
onUpdateDropTarget: (itemId: string | null) => void;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||
boxSelectedIds: string[],
|
||||
clearBoxSelection: () => void,
|
||||
getBoxSelection: () => string[],
|
||||
activeId: string | null,
|
||||
activeDragIds: string[],
|
||||
justMoved: boolean,
|
||||
isOver: boolean,
|
||||
dragHandleProps?: any,
|
||||
zoomLevel?: number,
|
||||
) => React.ReactNode;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
const DraggableItem = <T extends DragDropItem>({
|
||||
item,
|
||||
index,
|
||||
itemRefs,
|
||||
boxSelectedPageIds,
|
||||
clearBoxSelection,
|
||||
getBoxSelection,
|
||||
activeId,
|
||||
activeDragIds,
|
||||
justMoved,
|
||||
getThumbnailData,
|
||||
renderItem,
|
||||
onUpdateDropTarget,
|
||||
zoomLevel,
|
||||
}: DraggableItemProps<T>) => {
|
||||
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
|
||||
id: item.id,
|
||||
data: {
|
||||
index,
|
||||
pageNumber: index + 1,
|
||||
getThumbnail: () => {
|
||||
if (getThumbnailData) {
|
||||
const data = getThumbnailData(item.id);
|
||||
if (data?.src) return data;
|
||||
}
|
||||
|
||||
const element = itemRefs.current.get(item.id);
|
||||
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
|
||||
if (imgElement?.src) {
|
||||
return {
|
||||
src: imgElement.src,
|
||||
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: item.id,
|
||||
data: { index, pageNumber: index + 1 },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOver) {
|
||||
onUpdateDropTarget(item.id);
|
||||
} else {
|
||||
onUpdateDropTarget(null);
|
||||
}
|
||||
}, [isOver, item.id, onUpdateDropTarget]);
|
||||
|
||||
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
|
||||
setDraggableRef(element);
|
||||
setDroppableRef(element);
|
||||
if (element) {
|
||||
itemRefs.current.set(item.id, element);
|
||||
} else {
|
||||
itemRefs.current.delete(item.id);
|
||||
}
|
||||
}, [item.id, setDraggableRef, setDroppableRef]);
|
||||
|
||||
return renderItem(
|
||||
item,
|
||||
index,
|
||||
itemRefs,
|
||||
boxSelectedPageIds,
|
||||
clearBoxSelection,
|
||||
getBoxSelection,
|
||||
activeId,
|
||||
activeDragIds,
|
||||
justMoved,
|
||||
isOver,
|
||||
{
|
||||
...attributes,
|
||||
...listeners,
|
||||
ref: setNodeRef,
|
||||
onPointerDown: (event: React.PointerEvent) => {
|
||||
event.preventDefault();
|
||||
listeners.onPointerDown?.(event as any);
|
||||
},
|
||||
},
|
||||
zoomLevel,
|
||||
);
|
||||
};
|
||||
|
||||
interface DragOverlayContentProps<T extends DragDropItem> {
|
||||
activeItem: T | null;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
const DragOverlayContent = <T extends DragDropItem>({ activeItem, getThumbnailData, zoomLevel }: DragOverlayContentProps<T>) => {
|
||||
const thumbnailData = activeItem && getThumbnailData ? getThumbnailData(activeItem.id) : null;
|
||||
|
||||
if (!activeItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
transform: zoomLevel !== 1 ? `scale(${zoomLevel})` : undefined,
|
||||
transformOrigin: 'top left',
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
border: '1px solid var(--mantine-color-border)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
{thumbnailData?.src ? (
|
||||
<img
|
||||
src={thumbnailData.src}
|
||||
alt="drag-preview"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: `calc(${GRID_CONSTANTS.ITEM_WIDTH} * ${zoomLevel})`,
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: GRID_CONSTANTS.ITEM_WIDTH,
|
||||
height: GRID_CONSTANTS.ITEM_HEIGHT,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
Moving page...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DragDropGrid = <T extends DragDropItem>({
|
||||
items,
|
||||
onReorderPages,
|
||||
renderItem,
|
||||
getThumbnailData,
|
||||
zoomLevel = 1,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Responsive grid configuration
|
||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
const boxSelectionRef = useRef<Set<string>>(new Set());
|
||||
const getBoxSelection = useCallback(() => Array.from(boxSelectionRef.current), []);
|
||||
const clearBoxSelection = useCallback(() => {
|
||||
boxSelectionRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||
const overscan = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [activeDragIds, setActiveDragIds] = useState<string[]>([]);
|
||||
const [justMovedIds, setJustMovedIds] = useState<Set<string>>(new Set());
|
||||
const [dropHint, setDropHint] = useState<DropHint>({ hoveredId: null, dropSide: null });
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Calculate items per row based on container width
|
||||
const calculateItemsPerRow = useCallback(() => {
|
||||
if (!containerRef.current) return 4; // Default fallback
|
||||
if (!containerRef.current) return 4;
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
if (containerWidth === 0) return 4; // Container not measured yet
|
||||
if (containerWidth === 0) return 4;
|
||||
|
||||
// Convert rem to pixels for calculation
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
|
||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||
const availableWidth = containerWidth - ITEM_GAP;
|
||||
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
||||
const calculated = Math.floor(availableWidth / itemWithGap);
|
||||
|
||||
return Math.max(1, calculated); // At least 1 item per row
|
||||
return Math.max(1, calculated);
|
||||
}, []);
|
||||
|
||||
// Update items per row when container resizes
|
||||
useEffect(() => {
|
||||
const updateLayout = () => {
|
||||
const newItemsPerRow = calculateItemsPerRow();
|
||||
setItemsPerRow(newItemsPerRow);
|
||||
};
|
||||
|
||||
// Initial calculation
|
||||
updateLayout();
|
||||
|
||||
// Listen for window resize
|
||||
window.addEventListener('resize', updateLayout);
|
||||
|
||||
// Use ResizeObserver for container size changes
|
||||
const resizeObserver = new ResizeObserver(updateLayout);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
@@ -74,85 +392,270 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
};
|
||||
}, [calculateItemsPerRow]);
|
||||
|
||||
// Virtualization with react-virtual library
|
||||
const filteredItems = useMemo(() => items.filter(item => !item.isPlaceholder), [items]);
|
||||
const filteredToOriginalIndex = useMemo(() => {
|
||||
const result: number[] = [];
|
||||
items.forEach((item, index) => {
|
||||
if (!item.isPlaceholder) {
|
||||
result.push(index);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [items]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: Math.ceil(items.length / itemsPerRow),
|
||||
count: Math.ceil(filteredItems.length / itemsPerRow),
|
||||
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
||||
estimateSize: () => {
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
||||
},
|
||||
overscan: OVERSCAN,
|
||||
overscan,
|
||||
});
|
||||
|
||||
// Calculate optimal width for centering
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||
|
||||
const activeItem = activeId ? items.find(item => item.id === activeId) || null : null;
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
setActiveId(active.id as string);
|
||||
|
||||
const activeElement = itemRefs.current.get(active.id as string);
|
||||
if (activeElement) {
|
||||
activeElement.style.opacity = '0.2';
|
||||
}
|
||||
|
||||
const selectedIds = getBoxSelection();
|
||||
if (selectedIds.includes(active.id as string)) {
|
||||
setActiveDragIds(selectedIds);
|
||||
} else {
|
||||
setActiveDragIds([active.id as string]);
|
||||
}
|
||||
}, [getBoxSelection]);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active } = event;
|
||||
const activeIndex = filteredItems.findIndex(item => item.id === active.id);
|
||||
const fallbackIndex = activeIndex !== -1 ? filteredToOriginalIndex[activeIndex] : null;
|
||||
|
||||
if (!dropHint.hoveredId && fallbackIndex === null) {
|
||||
setDropHint({ hoveredId: null, dropSide: null });
|
||||
setActiveId(null);
|
||||
setActiveDragIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = resolveTargetIndex(
|
||||
dropHint.hoveredId,
|
||||
dropHint.dropSide,
|
||||
filteredItems,
|
||||
filteredToOriginalIndex,
|
||||
items.length,
|
||||
fallbackIndex,
|
||||
);
|
||||
|
||||
if (targetIndex !== null) {
|
||||
const pageNumber = filteredItems.findIndex(item => item.id === active.id) + 1;
|
||||
if (pageNumber > 0) {
|
||||
onReorderPages(pageNumber, targetIndex, activeDragIds);
|
||||
|
||||
const updatedJustMoved = new Set<string>(activeDragIds);
|
||||
setJustMovedIds(updatedJustMoved);
|
||||
|
||||
setTimeout(() => {
|
||||
setJustMovedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
activeDragIds.forEach(id => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
setDropHint({ hoveredId: null, dropSide: null });
|
||||
setActiveId(null);
|
||||
setActiveDragIds([]);
|
||||
|
||||
const activeElement = itemRefs.current.get(active.id as string);
|
||||
if (activeElement) {
|
||||
activeElement.style.opacity = '';
|
||||
}
|
||||
}, [activeDragIds, dropHint, filteredItems, filteredToOriginalIndex, items.length, onReorderPages]);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setDropHint({ hoveredId: null, dropSide: null });
|
||||
setActiveId(null);
|
||||
setActiveDragIds([]);
|
||||
}, []);
|
||||
|
||||
const handleDragMove = useCallback((event: DragStartEvent | DragEndEvent) => {
|
||||
const { active, delta } = event;
|
||||
if (!active) return;
|
||||
|
||||
const referenceElement = itemRefs.current.get(active.id as string);
|
||||
if (!referenceElement) return;
|
||||
|
||||
const referenceRect = referenceElement.getBoundingClientRect();
|
||||
const cursorX = referenceRect.left + delta.x;
|
||||
const cursorY = referenceRect.top + delta.y;
|
||||
|
||||
const hint = resolveDropHint(active.id as string, itemRefs, cursorX, cursorY);
|
||||
setDropHint(hint);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = containerRef.current?.closest('[data-scrolling-container]');
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('wheel', handleWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getDropIndicatorStyle = useCallback((itemId: string) => {
|
||||
if (dropHint.hoveredId !== itemId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (dropHint.dropSide === 'left') {
|
||||
return {
|
||||
boxShadow: '-4px 0 0 0 var(--mantine-primary-color-filled)',
|
||||
};
|
||||
}
|
||||
|
||||
if (dropHint.dropSide === 'right') {
|
||||
return {
|
||||
boxShadow: '4px 0 0 0 var(--mantine-primary-color-filled)',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}, [dropHint]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragMove={handleDragMove}
|
||||
>
|
||||
<div
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
margin: '0 auto',
|
||||
maxWidth: `${gridWidth}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||
const rowItems = items.slice(startIndex, endIndex);
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
margin: '0 auto',
|
||||
maxWidth: `${gridWidth}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, filteredItems.length);
|
||||
const rowItems = filteredItems.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: GRID_CONSTANTS.ITEM_GAP,
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Item */}
|
||||
{renderItem(item, actualIndex, itemRefs)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: GRID_CONSTANTS.ITEM_GAP,
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
const originalIndex = filteredToOriginalIndex[actualIndex];
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<DraggableItem
|
||||
item={item}
|
||||
index={originalIndex}
|
||||
itemRefs={itemRefs}
|
||||
boxSelectedPageIds={getBoxSelection()}
|
||||
clearBoxSelection={clearBoxSelection}
|
||||
getBoxSelection={getBoxSelection}
|
||||
activeId={activeId}
|
||||
activeDragIds={activeDragIds}
|
||||
justMoved={justMovedIds.has(item.id)}
|
||||
getThumbnailData={getThumbnailData}
|
||||
zoomLevel={zoomLevel}
|
||||
onUpdateDropTarget={setDropTargetId}
|
||||
renderItem={(...args) => {
|
||||
const node = renderItem(...args);
|
||||
const style = getDropIndicatorStyle(item.id);
|
||||
return <div style={style}>{node}</div>;
|
||||
}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{dropTargetId && dropHint.hoveredId === dropTargetId && dropHint.dropSide && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: dropHint.dropSide === 'left' ? '-0.25rem' : undefined,
|
||||
right: dropHint.dropSide === 'right' ? '-0.25rem' : undefined,
|
||||
width: '0.25rem',
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
borderRadius: '9999px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlayContent
|
||||
activeItem={activeItem || null}
|
||||
getThumbnailData={getThumbnailData}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export type { DragDropItem };
|
||||
export default DragDropGrid;
|
||||
|
||||
@@ -11,6 +11,26 @@
|
||||
transform: scale(1.02) translateZ(0);
|
||||
}
|
||||
|
||||
.pageSurface {
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.pageJustMoved {
|
||||
animation: pageMovedHighlight 1.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pageMovedHighlight {
|
||||
0% {
|
||||
background-color: rgba(59, 130, 246, 0.32);
|
||||
}
|
||||
60% {
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageNumber {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useNavigationGuard } from "@app/contexts/NavigationContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "@app/types/pageEditor";
|
||||
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
||||
import { PDFDocument, PDFPage, PageEditorFunctions } from "@app/types/pageEditor";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { pdfExportService } from "@app/services/pdfExportService";
|
||||
import { documentManipulationService } from "@app/services/documentManipulationService";
|
||||
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
|
||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
import '@app/components/pageEditor/PageEditor.module.css';
|
||||
import PageThumbnail from '@app/components/pageEditor/PageThumbnail';
|
||||
import DragDropGrid from '@app/components/pageEditor/DragDropGrid';
|
||||
import SkeletonLoader from '@app/components/shared/SkeletonLoader';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import './PageEditor.module.css';
|
||||
import PageThumbnail from './PageThumbnail';
|
||||
import DragDropGrid from './DragDropGrid';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import NavigationWarningModal from '../shared/NavigationWarningModal';
|
||||
import { FileId } from "@app/types/file";
|
||||
|
||||
import {
|
||||
@@ -22,12 +23,14 @@ import {
|
||||
BulkRotateCommand,
|
||||
PageBreakCommand,
|
||||
UndoManager
|
||||
} from '@app/components/pageEditor/commands/pageCommands';
|
||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
||||
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
|
||||
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
|
||||
} from './commands/pageCommands';
|
||||
import { GRID_CONSTANTS } from './constants';
|
||||
import { useInitialPageDocument } from './hooks/useInitialPageDocument';
|
||||
import { usePageDocument } from './hooks/usePageDocument';
|
||||
import { usePageEditorState } from './hooks/usePageEditorState';
|
||||
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
|
||||
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
|
||||
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
|
||||
|
||||
export interface PageEditorProps {
|
||||
onFunctionsReady?: (functions: PageEditorFunctions) => void;
|
||||
@@ -44,8 +47,89 @@ const PageEditor = ({
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
// Get PageEditor coordination functions
|
||||
const { updateFileOrderFromPages, fileOrder, reorderedPages, clearReorderedPages, updateCurrentPages } = usePageEditor();
|
||||
|
||||
// Zoom state management
|
||||
const [zoomLevel, setZoomLevel] = useState(1.0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
||||
|
||||
// Zoom actions
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoomLevel(prev => Math.min(prev + 0.1, 3.0));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomLevel(prev => Math.max(prev - 0.1, 0.5));
|
||||
}, []);
|
||||
|
||||
// Derive page editor files from PageEditorContext's fileOrder (page editor workspace order)
|
||||
// Filter to only show PDF files (PageEditor only supports PDFs)
|
||||
// Use stable string keys to prevent infinite loops
|
||||
// Cache file objects to prevent infinite re-renders from new object references
|
||||
const fileOrderKey = fileOrder.join(',');
|
||||
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(',');
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
const fileObjectsRef = useRef(new Map<FileId, any>());
|
||||
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
|
||||
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
|
||||
|
||||
const pageEditorFiles = useMemo(() => {
|
||||
const cache = fileObjectsRef.current;
|
||||
const newFiles: any[] = [];
|
||||
|
||||
fileOrder.forEach(fileId => {
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
const isSelected = state.ui.selectedFileIds.includes(fileId);
|
||||
const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
|
||||
|
||||
if (!isPdf) return; // Skip non-PDFs
|
||||
|
||||
const cached = cache.get(fileId);
|
||||
|
||||
// Check if data actually changed (compare by fileId, not position)
|
||||
if (cached &&
|
||||
cached.fileId === fileId &&
|
||||
cached.name === (stub?.name || '') &&
|
||||
cached.versionNumber === stub?.versionNumber &&
|
||||
cached.isSelected === isSelected) {
|
||||
// Reuse existing object reference
|
||||
newFiles.push(cached);
|
||||
} else {
|
||||
// Create new object only if data changed
|
||||
const newFile = {
|
||||
fileId,
|
||||
name: stub?.name || '',
|
||||
versionNumber: stub?.versionNumber,
|
||||
isSelected,
|
||||
};
|
||||
cache.set(fileId, newFile);
|
||||
newFiles.push(newFile);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up removed files from cache
|
||||
const activeIds = new Set(newFiles.map(f => f.fileId));
|
||||
for (const cachedId of cache.keys()) {
|
||||
if (!activeIds.has(cachedId)) {
|
||||
cache.delete(cachedId);
|
||||
}
|
||||
}
|
||||
|
||||
return newFiles;
|
||||
}, [fileOrderKey, selectedIdsKey, filesSignature]);
|
||||
|
||||
// Get ALL file IDs in order (not filtered by selection)
|
||||
const orderedFileIds = useMemo(() => {
|
||||
return pageEditorFiles.map(f => f.fileId);
|
||||
}, [pageEditorFiles]);
|
||||
|
||||
// Get selected file IDs for filtering
|
||||
const selectedFileIds = useMemo(() => {
|
||||
return pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId);
|
||||
}, [pageEditorFiles]);
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
@@ -58,8 +142,151 @@ const PageEditor = ({
|
||||
const undoManagerRef = useRef(new UndoManager());
|
||||
|
||||
// Document state management
|
||||
// Get initial document ONCE - useInitialPageDocument captures first value only
|
||||
const initialDocument = useInitialPageDocument();
|
||||
|
||||
// Also get live mergedPdfDocument for delta sync (source of truth for page existence)
|
||||
const { document: mergedPdfDocument } = usePageDocument();
|
||||
|
||||
// Initialize editedDocument from initial document
|
||||
useEffect(() => {
|
||||
if (!initialDocument || editedDocument) return;
|
||||
|
||||
console.log('📄 Initializing editedDocument from initial document:', initialDocument.pages.length, 'pages');
|
||||
|
||||
// Clone to avoid mutation
|
||||
setEditedDocument({
|
||||
...initialDocument,
|
||||
pages: initialDocument.pages.map(p => ({ ...p }))
|
||||
});
|
||||
}, [initialDocument, editedDocument]);
|
||||
|
||||
// Apply file reordering from PageEditorContext
|
||||
useEffect(() => {
|
||||
if (reorderedPages && editedDocument) {
|
||||
setEditedDocument({
|
||||
...editedDocument,
|
||||
pages: reorderedPages
|
||||
});
|
||||
clearReorderedPages();
|
||||
}
|
||||
}, [reorderedPages, editedDocument, clearReorderedPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editedDocument) return;
|
||||
const positionCache = pagePositionCacheRef.current;
|
||||
const neighborCache = pageNeighborCacheRef.current;
|
||||
const pages = editedDocument.pages;
|
||||
pages.forEach((page, index) => {
|
||||
positionCache.set(page.id, index);
|
||||
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
|
||||
});
|
||||
}, [editedDocument]);
|
||||
|
||||
// Live delta sync: reflect external add/remove without touching existing order
|
||||
useEffect(() => {
|
||||
if (!mergedPdfDocument || !editedDocument) return;
|
||||
|
||||
const sourcePages = mergedPdfDocument.pages;
|
||||
const sourceIds = new Set(sourcePages.map(p => p.id));
|
||||
|
||||
// Group new pages by file (preserve within-file order from source)
|
||||
const prevIds = new Set(editedDocument.pages.map(p => p.id));
|
||||
const newPages: PDFPage[] = [];
|
||||
for (const p of sourcePages) {
|
||||
if (!prevIds.has(p.id)) {
|
||||
newPages.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Fast check: changes exist?
|
||||
const hasAdditions = newPages.length > 0;
|
||||
let hasRemovals = false;
|
||||
for (const p of editedDocument.pages) {
|
||||
if (!sourceIds.has(p.id)) {
|
||||
hasRemovals = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAdditions && !hasRemovals) return;
|
||||
|
||||
setEditedDocument(prev => {
|
||||
if (!prev) return prev;
|
||||
let pages = [...prev.pages];
|
||||
|
||||
// Capture placeholder positions before they are removed so we can restore files without disrupting current order
|
||||
const placeholderPositions = new Map<FileId, number>();
|
||||
pages.forEach((page, index) => {
|
||||
if (page.isPlaceholder && page.originalFileId) {
|
||||
placeholderPositions.set(page.originalFileId, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Track next insertion index per file when replacing placeholders
|
||||
const nextInsertIndexByFile = new Map(placeholderPositions);
|
||||
|
||||
// Remove pages that no longer exist in source
|
||||
if (hasRemovals) {
|
||||
pages = pages.filter(p => sourceIds.has(p.id));
|
||||
}
|
||||
|
||||
// Insert new pages while preserving current interleaving
|
||||
if (hasAdditions) {
|
||||
const mergedIndexMap = new Map<string, number>();
|
||||
sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index));
|
||||
|
||||
const additions = newPages
|
||||
.map(page => ({
|
||||
page,
|
||||
cachedIndex: pagePositionCacheRef.current.get(page.id),
|
||||
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
|
||||
neighborId: pageNeighborCacheRef.current.get(page.id)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.cachedIndex ?? a.mergedIndex;
|
||||
const bIndex = b.cachedIndex ?? b.mergedIndex;
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
return a.mergedIndex - b.mergedIndex;
|
||||
});
|
||||
|
||||
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
|
||||
if (pages.some(existing => existing.id === page.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let insertIndex: number;
|
||||
const originalFileId = page.originalFileId;
|
||||
const placeholderIndex = originalFileId ? nextInsertIndexByFile.get(originalFileId) : undefined;
|
||||
|
||||
if (originalFileId && placeholderIndex !== undefined) {
|
||||
insertIndex = Math.min(placeholderIndex, pages.length);
|
||||
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
|
||||
} else if (neighborId === null) {
|
||||
insertIndex = 0;
|
||||
} else if (neighborId) {
|
||||
const neighborIndex = pages.findIndex(p => p.id === neighborId);
|
||||
if (neighborIndex !== -1) {
|
||||
insertIndex = neighborIndex + 1;
|
||||
} else {
|
||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||
}
|
||||
} else {
|
||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||
}
|
||||
|
||||
const clonedPage = { ...page };
|
||||
pages.splice(insertIndex, 0, clonedPage);
|
||||
});
|
||||
}
|
||||
|
||||
// Renumber without reordering
|
||||
pages = pages.map((p, i) => ({ ...p, pageNumber: i + 1 }));
|
||||
return { ...prev, pages };
|
||||
});
|
||||
}, [fileOrder.join(','), mergedPdfDocument && mergedPdfDocument.pages.map(p => p.id).join(',')]);
|
||||
|
||||
// UI state management
|
||||
const {
|
||||
@@ -86,9 +313,14 @@ const PageEditor = ({
|
||||
|
||||
// Update undo/redo state
|
||||
const updateUndoRedoState = useCallback(() => {
|
||||
setCanUndo(undoManagerRef.current.canUndo());
|
||||
setCanRedo(undoManagerRef.current.canRedo());
|
||||
}, []);
|
||||
const undoManager = undoManagerRef.current;
|
||||
setCanUndo(undoManager.canUndo());
|
||||
setCanRedo(undoManager.canRedo());
|
||||
|
||||
if (!undoManager.hasHistory()) {
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, [setHasUnsavedChanges]);
|
||||
|
||||
// Set up undo manager callback
|
||||
useEffect(() => {
|
||||
@@ -126,9 +358,17 @@ const PageEditor = ({
|
||||
}, []);
|
||||
|
||||
// Interface functions for parent component
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
const totalPages = displayDocument?.pages.length ?? 0;
|
||||
const displayDocument = editedDocument || initialDocument;
|
||||
|
||||
// Feed current pages to PageEditorContext so file reordering can compute page-level changes
|
||||
useEffect(() => {
|
||||
updateCurrentPages(displayDocument?.pages ?? null);
|
||||
}, [displayDocument, updateCurrentPages]);
|
||||
|
||||
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
|
||||
const totalPages = displayDocument?.pages.length || 0;
|
||||
const selectedPageCount = selectedPageIds.length;
|
||||
const activeFileIds = selectedFileIds;
|
||||
|
||||
// Utility functions to convert between page IDs and page numbers
|
||||
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
|
||||
@@ -158,6 +398,31 @@ const PageEditor = ({
|
||||
}
|
||||
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
|
||||
|
||||
// Automatically include newly added pages in the current selection
|
||||
const previousPageIdsRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!displayDocument || displayDocument.pages.length === 0) {
|
||||
previousPageIdsRef.current = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(displayDocument.pages.map(page => page.id));
|
||||
const newlyAddedPageIds: string[] = [];
|
||||
currentIds.forEach(id => {
|
||||
if (!previousPageIdsRef.current.has(id)) {
|
||||
newlyAddedPageIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (newlyAddedPageIds.length > 0) {
|
||||
const next = new Set(selectedPageIds);
|
||||
newlyAddedPageIds.forEach(id => next.add(id));
|
||||
setSelectedPageIds(Array.from(next));
|
||||
}
|
||||
|
||||
previousPageIdsRef.current = currentIds;
|
||||
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
|
||||
|
||||
// DOM-first command handlers
|
||||
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
@@ -323,51 +588,8 @@ const PageEditor = ({
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplitAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
// Convert selected page IDs to page numbers, then to split positions (0-based indices)
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
const selectedPositions: number[] = [];
|
||||
selectedPageNumbers.forEach(pageNum => {
|
||||
const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum);
|
||||
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
|
||||
// Only allow splits before the last page
|
||||
selectedPositions.push(pageIndex);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedPositions.length === 0) return;
|
||||
|
||||
// Smart toggle logic: follow the majority, default to adding splits if equal
|
||||
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
|
||||
const noSplitsCount = selectedPositions.length - existingSplitsCount;
|
||||
|
||||
// Remove splits only if majority already have splits
|
||||
// If equal (50/50), default to adding splits
|
||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
const newSplitPositions = new Set(splitPositions);
|
||||
|
||||
if (shouldRemoveSplits) {
|
||||
// Remove splits from all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
|
||||
} else {
|
||||
// Add splits to all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.add(pos));
|
||||
}
|
||||
|
||||
// Create a custom command that sets the final state directly
|
||||
const smartSplitCommand = {
|
||||
execute: () => setSplitPositions(newSplitPositions),
|
||||
undo: () => setSplitPositions(splitPositions),
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
// Alias for consistency - handleSplitAll is the same as handleSplit (both have smart toggle logic)
|
||||
const handleSplitAll = handleSplit;
|
||||
|
||||
const handlePageBreak = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -383,32 +605,99 @@ const PageEditor = ({
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreakAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
// Alias for consistency - handlePageBreakAll is the same as handlePageBreak
|
||||
const handlePageBreakAll = handlePageBreak;
|
||||
|
||||
// Convert selected page IDs to page numbers for the command
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
|
||||
const pageBreakCommand = new PageBreakCommand(
|
||||
selectedPageNumbers,
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
|
||||
if (!displayDocument || files.length === 0) return;
|
||||
const handleInsertFiles = useCallback(async (
|
||||
files: File[] | StirlingFileStub[],
|
||||
insertAfterPage: number,
|
||||
isFromStorage?: boolean
|
||||
) => {
|
||||
if (!editedDocument || files.length === 0) return;
|
||||
|
||||
try {
|
||||
const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage);
|
||||
const targetPage = editedDocument.pages.find(p => p.pageNumber === insertAfterPage);
|
||||
if (!targetPage) return;
|
||||
|
||||
await actions.addFiles(files, { insertAfterPageId: targetPage.id });
|
||||
console.log('📄 handleInsertFiles: Inserting files after page', insertAfterPage, 'targetPage:', targetPage.id);
|
||||
|
||||
// Add files to FileContext for metadata tracking and preserve insertion point
|
||||
const insertAfterPageId = targetPage.id;
|
||||
let addedFileIds: FileId[] = [];
|
||||
if (isFromStorage) {
|
||||
const stubs = files as StirlingFileStub[];
|
||||
const result = await actions.addStirlingFileStubs(stubs, {
|
||||
selectFiles: true,
|
||||
insertAfterPageId
|
||||
});
|
||||
addedFileIds = result.map(f => f.fileId);
|
||||
console.log('📄 handleInsertFiles: Added stubs, IDs:', addedFileIds);
|
||||
} else {
|
||||
const result = await actions.addFiles(files as File[], {
|
||||
selectFiles: true,
|
||||
insertAfterPageId
|
||||
});
|
||||
addedFileIds = result.map(f => f.fileId);
|
||||
console.log('📄 handleInsertFiles: Added files, IDs:', addedFileIds);
|
||||
}
|
||||
|
||||
// Wait a moment for files to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Extract pages from newly added files and insert them into editedDocument
|
||||
const newPages: PDFPage[] = [];
|
||||
for (const fileId of addedFileIds) {
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
console.log('📄 handleInsertFiles: File', fileId, 'stub:', stub?.name, 'processedFile:', stub?.processedFile?.totalPages, 'pages:', stub?.processedFile?.pages?.length);
|
||||
if (stub?.processedFile?.pages) {
|
||||
// Clone pages and ensure proper PDFPage structure
|
||||
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
|
||||
...page,
|
||||
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
|
||||
pageNumber: page.pageNumber ?? idx + 1,
|
||||
originalFileId: fileId,
|
||||
originalPageNumber: page.originalPageNumber ?? page.pageNumber ?? idx + 1,
|
||||
rotation: page.rotation ?? 0,
|
||||
thumbnail: page.thumbnail ?? null,
|
||||
selected: false,
|
||||
splitAfter: page.splitAfter ?? false,
|
||||
}));
|
||||
newPages.push(...clonedPages);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📄 handleInsertFiles: Collected', newPages.length, 'new pages');
|
||||
|
||||
if (newPages.length > 0) {
|
||||
// Find insertion index in editedDocument
|
||||
const targetIndex = editedDocument.pages.findIndex(p => p.id === targetPage.id);
|
||||
console.log('📄 handleInsertFiles: Target index in editedDocument:', targetIndex);
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
// Clone pages and insert
|
||||
const updatedPages = [...editedDocument.pages];
|
||||
updatedPages.splice(targetIndex + 1, 0, ...newPages);
|
||||
|
||||
// Renumber all pages
|
||||
updatedPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
|
||||
console.log('📄 handleInsertFiles: Updated pages:', updatedPages.map(p => `${p.id}(${p.pageNumber})`));
|
||||
|
||||
setEditedDocument({
|
||||
...editedDocument,
|
||||
pages: updatedPages
|
||||
});
|
||||
|
||||
// Keep PageEditor file order in sync with newly inserted pages
|
||||
updateFileOrderFromPages(updatedPages);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to insert files:', error);
|
||||
}
|
||||
}, [displayDocument, actions]);
|
||||
}, [editedDocument, actions, selectors, updateFileOrderFromPages]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (!displayDocument) return;
|
||||
@@ -442,17 +731,18 @@ const PageEditor = ({
|
||||
targetIndex,
|
||||
selectedPages,
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
setEditedDocument,
|
||||
(newPages) => updateFileOrderFromPages(newPages) // Sync file order when pages are reordered
|
||||
);
|
||||
executeCommandWithTracking(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking, updateFileOrderFromPages]);
|
||||
|
||||
// Helper function to collect source files for multi-file export
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
const sourceFiles = new Map<FileId, File>();
|
||||
|
||||
// Always include original files
|
||||
activeFileIds.forEach(fileId => {
|
||||
// Always include selected files
|
||||
selectedFileIds.forEach(fileId => {
|
||||
const file = selectors.getFile(fileId);
|
||||
if (file) {
|
||||
sourceFiles.set(fileId, file);
|
||||
@@ -461,31 +751,31 @@ const PageEditor = ({
|
||||
|
||||
// Use multi-file export if we have multiple original files
|
||||
const hasInsertedFiles = false;
|
||||
const hasMultipleOriginalFiles = activeFileIds.length > 1;
|
||||
const hasMultipleOriginalFiles = selectedFileIds.length > 1;
|
||||
|
||||
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
|
||||
return null; // Use single-file export method
|
||||
}
|
||||
|
||||
return sourceFiles.size > 0 ? sourceFiles : null;
|
||||
}, [activeFileIds, selectors]);
|
||||
}, [selectedFileIds, selectors]);
|
||||
|
||||
// Helper function to generate proper filename for exports
|
||||
const getExportFilename = useCallback((): string => {
|
||||
if (activeFileIds.length <= 1) {
|
||||
if (selectedFileIds.length <= 1) {
|
||||
// Single file - use original name
|
||||
return displayDocument?.name || 'document.pdf';
|
||||
}
|
||||
|
||||
// Multiple files - use first file name with " (merged)" suffix
|
||||
const firstFile = selectors.getFile(activeFileIds[0]);
|
||||
const firstFile = selectors.getFile(selectedFileIds[0]);
|
||||
if (firstFile) {
|
||||
const baseName = firstFile.name.replace(/\.pdf$/i, '');
|
||||
return `${baseName} (merged).pdf`;
|
||||
}
|
||||
|
||||
return 'merged-document.pdf';
|
||||
}, [activeFileIds, selectors, displayDocument]);
|
||||
}, [selectedFileIds, selectors, displayDocument]);
|
||||
|
||||
const onExportSelected = useCallback(async () => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -494,7 +784,7 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Original order (editedDocument is our working doc now)
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
);
|
||||
@@ -534,7 +824,7 @@ const PageEditor = ({
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
}, [displayDocument, selectedPageIds, initialDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
const onExportAll = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
@@ -543,7 +833,7 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
@@ -580,7 +870,7 @@ const PageEditor = ({
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
}, [displayDocument, initialDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(async () => {
|
||||
@@ -590,7 +880,7 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
@@ -600,14 +890,11 @@ const PageEditor = ({
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
// Step 3: Add merged output as new files while keeping originals
|
||||
const newStirlingFiles = await actions.addFiles(files, { selectFiles: true });
|
||||
if (newStirlingFiles.length > 0) {
|
||||
actions.setSelectedFiles(newStirlingFiles.map(file => file.fileId));
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
@@ -615,7 +902,7 @@ const PageEditor = ({
|
||||
console.error('Apply changes failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
|
||||
}, [displayDocument, initialDocument, splitPositions, getSourceFiles, getExportFilename, actions, setHasUnsavedChanges]);
|
||||
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
@@ -638,6 +925,7 @@ const PageEditor = ({
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
onSaveChanges: applyChanges,
|
||||
exportLoading,
|
||||
activeFileCount: activeFileIds.length,
|
||||
closePdf,
|
||||
@@ -692,14 +980,88 @@ const PageEditor = ({
|
||||
selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf
|
||||
]);
|
||||
|
||||
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
|
||||
useEffect(() => {
|
||||
let accumulator = 0;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
accumulator += event.deltaY;
|
||||
const threshold = 10;
|
||||
|
||||
if (accumulator <= -threshold) {
|
||||
// Accumulated scroll up - zoom in
|
||||
zoomIn();
|
||||
accumulator = 0;
|
||||
} else if (accumulator >= threshold) {
|
||||
// Accumulated scroll down - zoom out
|
||||
zoomOut();
|
||||
accumulator = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => {
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}
|
||||
}, [zoomIn, zoomOut]);
|
||||
|
||||
// Handle keyboard zoom shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isContainerHovered) return;
|
||||
|
||||
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === '=' || event.key === '+') {
|
||||
// Ctrl+= or Ctrl++ for zoom in
|
||||
event.preventDefault();
|
||||
zoomIn();
|
||||
} else if (event.key === '-' || event.key === '_') {
|
||||
// Ctrl+- for zoom out
|
||||
event.preventDefault();
|
||||
zoomOut();
|
||||
} else if (event.key === '0') {
|
||||
// Ctrl+0 for reset zoom
|
||||
event.preventDefault();
|
||||
setZoomLevel(1.0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isContainerHovered, zoomIn, zoomOut]);
|
||||
|
||||
// Display all pages - use edited or original document
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
// Track color assignments by insertion order (files keep their color)
|
||||
const fileColorIndexMap = useFileColorMap(orderedFileIds);
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
pos="relative"
|
||||
h='100%'
|
||||
style={{ overflow: 'auto' }}
|
||||
data-scrolling-container="true"
|
||||
onMouseEnter={() => setIsContainerHovered(true)}
|
||||
onMouseLeave={() => setIsContainerHovered(false)}
|
||||
>
|
||||
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
||||
|
||||
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
|
||||
<Center h='100%'>
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📄</Text>
|
||||
@@ -709,7 +1071,7 @@ const PageEditor = ({
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{!mergedPdfDocument && globalProcessing && (
|
||||
{!initialDocument && globalProcessing && (
|
||||
<Box p={0}>
|
||||
<SkeletonLoader type="controls" />
|
||||
<SkeletonLoader type="pageGrid" count={8} />
|
||||
@@ -717,21 +1079,20 @@ const PageEditor = ({
|
||||
)}
|
||||
|
||||
{displayDocument && (
|
||||
<Box ref={gridContainerRef} p={0} pb="15rem" style={{ position: 'relative' }}>
|
||||
<Box ref={gridContainerRef} p={0} pt="2rem" pb="15rem" style={{ position: 'relative' }}>
|
||||
|
||||
|
||||
{/* Split Lines Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
{/* Split Lines Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Calculate remToPx once outside the map to avoid layout thrashing
|
||||
const containerWidth = containerDimensions.width;
|
||||
@@ -757,7 +1118,7 @@ const PageEditor = ({
|
||||
const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2);
|
||||
|
||||
const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2);
|
||||
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height)
|
||||
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05) + ITEM_GAP; // Center vertically (5% offset since page is 90% height) + gap offset
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -779,40 +1140,56 @@ const PageEditor = ({
|
||||
{/* Pages Grid */}
|
||||
<DragDropGrid
|
||||
items={displayedPages}
|
||||
selectedItems={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onReorderPages={handleReorderPages}
|
||||
renderItem={(page, index, refs) => (
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={displayDocument.pages.length}
|
||||
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
|
||||
selectedPageIds={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
pageRefs={refs}
|
||||
onReorderPages={handleReorderPages}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
onExecuteCommand={executeCommand}
|
||||
onSetStatus={() => {}}
|
||||
onSetMovingPage={setMovingPage}
|
||||
onDeletePage={handleDeletePage}
|
||||
createRotateCommand={createRotateCommand}
|
||||
createDeleteCommand={createDeleteCommand}
|
||||
createSplitCommand={createSplitCommand}
|
||||
pdfDocument={displayDocument}
|
||||
setPdfDocument={setEditedDocument}
|
||||
splitPositions={splitPositions}
|
||||
onInsertFiles={handleInsertFiles}
|
||||
/>
|
||||
)}
|
||||
zoomLevel={zoomLevel}
|
||||
getThumbnailData={(pageId) => {
|
||||
const page = displayDocument.pages.find(p => p.id === pageId);
|
||||
if (!page?.thumbnail) return null;
|
||||
return {
|
||||
src: page.thumbnail,
|
||||
rotation: page.rotation || 0
|
||||
};
|
||||
}}
|
||||
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, _getBoxSelection, _activeId, activeDragIds, justMoved, _isOver, dragHandleProps, zoomLevel) => {
|
||||
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
|
||||
const isBoxSelected = boxSelectedIds.includes(page.id);
|
||||
return (
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={displayDocument.pages.length}
|
||||
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
|
||||
fileColorIndex={fileColorIndex}
|
||||
selectedPageIds={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
isBoxSelected={isBoxSelected}
|
||||
clearBoxSelection={clearBoxSelection}
|
||||
activeDragIds={activeDragIds}
|
||||
justMoved={justMoved}
|
||||
pageRefs={refs}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onReorderPages={handleReorderPages}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
onExecuteCommand={executeCommand}
|
||||
onSetStatus={() => {}}
|
||||
onSetMovingPage={setMovingPage}
|
||||
onDeletePage={handleDeletePage}
|
||||
createRotateCommand={createRotateCommand}
|
||||
createDeleteCommand={createDeleteCommand}
|
||||
createSplitCommand={createSplitCommand}
|
||||
pdfDocument={displayDocument}
|
||||
setPdfDocument={setEditedDocument}
|
||||
splitPositions={splitPositions}
|
||||
onInsertFiles={handleInsertFiles}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -825,6 +1202,7 @@ const PageEditor = ({
|
||||
await onExportAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -138,12 +138,12 @@ const PageEditorControls = ({
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" style={{ color: canUndo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" style={{ color: canRedo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -156,7 +156,7 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -168,7 +168,7 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -180,7 +180,7 @@ const PageEditorControls = ({
|
||||
onClick={onDelete}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -192,7 +192,7 @@ const PageEditorControls = ({
|
||||
onClick={onSplit}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -204,7 +204,7 @@ const PageEditorControls = ({
|
||||
onClick={onPageBreak}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
|
||||
@@ -8,12 +8,13 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
|
||||
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
|
||||
|
||||
interface PageThumbnailProps {
|
||||
@@ -21,11 +22,17 @@ interface PageThumbnailProps {
|
||||
index: number;
|
||||
totalPages: number;
|
||||
originalFile?: File;
|
||||
fileColorIndex: number;
|
||||
selectedPageIds: string[];
|
||||
selectionMode: boolean;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
isBoxSelected?: boolean;
|
||||
clearBoxSelection?: () => void;
|
||||
activeDragIds: string[];
|
||||
justMoved?: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
dragHandleProps?: any;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: () => void;
|
||||
@@ -39,7 +46,8 @@ interface PageThumbnailProps {
|
||||
pdfDocument: PDFDocument;
|
||||
setPdfDocument: (doc: PDFDocument) => void;
|
||||
splitPositions: Set<number>;
|
||||
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
|
||||
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
|
||||
zoomLevel?: number;
|
||||
}
|
||||
|
||||
const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
@@ -47,11 +55,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
fileColorIndex,
|
||||
selectedPageIds,
|
||||
selectionMode,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
isBoxSelected = false,
|
||||
clearBoxSelection,
|
||||
activeDragIds,
|
||||
pageRefs,
|
||||
dragHandleProps,
|
||||
onReorderPages,
|
||||
onTogglePage,
|
||||
onExecuteCommand,
|
||||
@@ -63,17 +76,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
pdfDocument,
|
||||
splitPositions,
|
||||
onInsertFiles,
|
||||
zoomLevel = 1.0,
|
||||
justMoved = false,
|
||||
}: PageThumbnailProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const lastClickTimeRef = useRef<number>(0);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
// Check if this page is currently being dragged
|
||||
const isDragging = activeDragIds.includes(page.id);
|
||||
|
||||
// Calculate document aspect ratio from first non-blank page
|
||||
const getDocumentAspectRatio = useCallback(() => {
|
||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||
@@ -130,63 +148,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
};
|
||||
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
|
||||
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
// Merge refs - combine our ref tracking with dnd-kit's ref
|
||||
const mergedRef = useCallback((element: HTMLDivElement | null) => {
|
||||
// Track in our refs map
|
||||
elementRef.current = element;
|
||||
if (element) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
pageNumber: page.pageNumber,
|
||||
pageId: page.id,
|
||||
selectedPageIds: [page.id]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (location.current.dropTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = location.current.dropTargets[0];
|
||||
const targetData = dropTarget.data;
|
||||
|
||||
if (targetData.type === 'page') {
|
||||
const targetPageNumber = targetData.pageNumber as number;
|
||||
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||
if (targetIndex !== -1) {
|
||||
onReorderPages(page.pageNumber, targetIndex, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
element.style.cursor = 'grab';
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: (_) => {}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
} else {
|
||||
pageRefs.current.delete(page.id);
|
||||
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||
(dragElementRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
|
||||
|
||||
// Call dnd-kit's ref if provided
|
||||
if (dragHandleProps?.ref) {
|
||||
dragHandleProps.ref(element);
|
||||
}
|
||||
}, [page.id, pageRefs, dragHandleProps]);
|
||||
|
||||
|
||||
// DOM command handlers
|
||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
||||
@@ -230,9 +207,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
// Open file manager modal with custom handler for page insertion
|
||||
openFilesModal({
|
||||
insertAfterPage: page.pageNumber,
|
||||
customHandler: (files: File[], insertAfterPage?: number) => {
|
||||
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
||||
if (insertAfterPage !== undefined) {
|
||||
onInsertFiles(files, insertAfterPage);
|
||||
onInsertFiles(files, insertAfterPage, isFromStorage);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -262,14 +239,28 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// If mouse moved less than 5 pixels, consider it a click (not a drag)
|
||||
if (distance < 5 && !isDragging) {
|
||||
onTogglePage(page.id);
|
||||
// If mouse moved less than 2 pixels, consider it a click (not a drag)
|
||||
if (distance < 2 && !isDragging) {
|
||||
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
|
||||
const now = Date.now();
|
||||
if (now - lastClickTimeRef.current > 100) {
|
||||
lastClickTimeRef.current = now;
|
||||
|
||||
// Clear box selection when clicking on a non-selected page
|
||||
if (!isBoxSelected && clearBoxSelection) {
|
||||
clearBoxSelection();
|
||||
}
|
||||
|
||||
// Don't toggle page selection if it's box-selected (just keep the box selection)
|
||||
if (!isBoxSelected) {
|
||||
onTogglePage(page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
@@ -277,6 +268,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||
|
||||
// Spread dragHandleProps but use our merged ref
|
||||
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
@@ -345,7 +341,8 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
ref={mergedRef}
|
||||
{...restDragProps}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
className={`
|
||||
@@ -353,24 +350,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
!rounded-lg
|
||||
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[20rem]
|
||||
flex items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
|
||||
`}
|
||||
style={{
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
width: `calc(20rem * ${zoomLevel})`,
|
||||
height: `calc(20rem * ${zoomLevel})`,
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
||||
zIndex: isHovered ? 50 : 1,
|
||||
...(isBoxSelected && {
|
||||
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
|
||||
}),
|
||||
}}
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@@ -413,12 +411,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||
<div
|
||||
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -439,7 +438,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: 2
|
||||
}}></div>
|
||||
}} />
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<img
|
||||
@@ -474,7 +473,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
|
||||
background: 'rgba(162, 201, 255, 0.8)',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
zIndex: 2,
|
||||
|
||||
@@ -161,7 +161,8 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
private targetIndex: number,
|
||||
private selectedPages: number[] | undefined,
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private onReorderComplete?: (newPages: PDFPage[]) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -196,7 +197,13 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
} else {
|
||||
// Single page reorder
|
||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||
newPages.splice(this.targetIndex, 0, movedPage);
|
||||
|
||||
// Adjust target index if moving forward (after removal, indices shift)
|
||||
const adjustedTargetIndex = sourceIndex < this.targetIndex
|
||||
? this.targetIndex - 1
|
||||
: this.targetIndex;
|
||||
|
||||
newPages.splice(adjustedTargetIndex, 0, movedPage);
|
||||
|
||||
newPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
@@ -210,6 +217,11 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
};
|
||||
|
||||
this.setDocument(reorderedDocument);
|
||||
|
||||
// Notify that reordering is complete
|
||||
if (this.onReorderComplete) {
|
||||
this.onReorderComplete(newPages);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
@@ -408,6 +420,14 @@ export class SplitAllCommand extends DOMCommand {
|
||||
}
|
||||
}
|
||||
|
||||
export type PageSize = 'A4' | 'Letter' | 'Legal' | 'A3' | 'A5';
|
||||
export type PageOrientation = 'portrait' | 'landscape';
|
||||
|
||||
export interface PageBreakSettings {
|
||||
size: PageSize;
|
||||
orientation: PageOrientation;
|
||||
}
|
||||
|
||||
export class PageBreakCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
@@ -415,7 +435,8 @@ export class PageBreakCommand extends DOMCommand {
|
||||
constructor(
|
||||
private selectedPageNumbers: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private settings?: PageBreakSettings
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -450,7 +471,8 @@ export class PageBreakCommand extends DOMCommand {
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: true // Custom flag for blank pages
|
||||
isBlankPage: true, // Custom flag for blank pages
|
||||
pageBreakSettings: this.settings // Store settings for export
|
||||
};
|
||||
newPages.push(blankPage);
|
||||
this.insertedPages.push(blankPage);
|
||||
@@ -694,7 +716,7 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
|
||||
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
|
||||
try {
|
||||
const { thumbnailGenerationService } = await import('@app/services/thumbnailGenerationService');
|
||||
const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService');
|
||||
|
||||
// Group pages by file ID to generate thumbnails efficiently
|
||||
const pagesByFileId = new Map<FileId, PDFPage[]>();
|
||||
@@ -776,7 +798,7 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
const clonedArrayBuffer = arrayBuffer.slice(0);
|
||||
|
||||
// Use PDF.js via the worker manager to extract pages
|
||||
const { pdfWorkerManager } = await import('@app/services/pdfWorkerManager');
|
||||
const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
@@ -883,6 +905,10 @@ export class UndoManager {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
hasHistory(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
|
||||
61
frontend/src/core/components/pageEditor/fileColors.ts
Normal file
61
frontend/src/core/components/pageEditor/fileColors.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* File color palette for page editor
|
||||
* Each file gets a distinct color for visual organization
|
||||
* Colors are applied at 0.3 opacity for subtle highlighting
|
||||
* Maximum 20 files supported in page editor
|
||||
*/
|
||||
|
||||
export const FILE_COLORS = [
|
||||
// Subtle colors (1-6) - fit well with UI theme
|
||||
'rgb(59, 130, 246)', // Blue
|
||||
'rgb(16, 185, 129)', // Green
|
||||
'rgb(139, 92, 246)', // Purple
|
||||
'rgb(6, 182, 212)', // Cyan
|
||||
'rgb(20, 184, 166)', // Teal
|
||||
'rgb(99, 102, 241)', // Indigo
|
||||
|
||||
// Mid-range colors (7-12) - more distinct
|
||||
'rgb(244, 114, 182)', // Pink
|
||||
'rgb(251, 146, 60)', // Orange
|
||||
'rgb(234, 179, 8)', // Yellow
|
||||
'rgb(132, 204, 22)', // Lime
|
||||
'rgb(248, 113, 113)', // Red
|
||||
'rgb(168, 85, 247)', // Violet
|
||||
|
||||
// Vibrant colors (13-20) - maximum distinction
|
||||
'rgb(236, 72, 153)', // Fuchsia
|
||||
'rgb(245, 158, 11)', // Amber
|
||||
'rgb(34, 197, 94)', // Emerald
|
||||
'rgb(14, 165, 233)', // Sky
|
||||
'rgb(239, 68, 68)', // Rose
|
||||
'rgb(168, 162, 158)', // Stone
|
||||
'rgb(251, 191, 36)', // Gold
|
||||
'rgb(192, 132, 252)', // Light Purple
|
||||
] as const;
|
||||
|
||||
export const MAX_PAGE_EDITOR_FILES = 20;
|
||||
|
||||
/**
|
||||
* Get color for a file by its index
|
||||
* @param index - Zero-based file index
|
||||
* @returns RGB color string
|
||||
*/
|
||||
export function getFileColor(index: number): string {
|
||||
if (index < 0 || index >= FILE_COLORS.length) {
|
||||
console.warn(`File index ${index} out of range, using default color`);
|
||||
return FILE_COLORS[0];
|
||||
}
|
||||
return FILE_COLORS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color with specified opacity
|
||||
* @param index - Zero-based file index
|
||||
* @param opacity - Opacity value (0-1), defaults to 0.3
|
||||
* @returns RGBA color string
|
||||
*/
|
||||
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
|
||||
const rgb = getFileColor(index);
|
||||
// Convert rgb(r, g, b) to rgba(r, g, b, a)
|
||||
return rgb.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
/**
|
||||
* Maintains stable color assignments for a collection of file IDs.
|
||||
* Colors are assigned by insertion order and preserved across reorders.
|
||||
*/
|
||||
export function useFileColorMap(fileIds: FileId[]): Map<FileId, number> {
|
||||
const assignmentsRef = useRef(new Map<FileId, number>());
|
||||
|
||||
const serializedIds = useMemo(() => fileIds.join(','), [fileIds]);
|
||||
|
||||
return useMemo(() => {
|
||||
const assignments = assignmentsRef.current;
|
||||
const activeIds = new Set(fileIds);
|
||||
|
||||
// Remove colors for files that no longer exist
|
||||
for (const id of Array.from(assignments.keys())) {
|
||||
if (!activeIds.has(id)) {
|
||||
assignments.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign colors to any new files
|
||||
fileIds.forEach((id) => {
|
||||
if (!assignments.has(id)) {
|
||||
assignments.set(id, assignments.size);
|
||||
}
|
||||
});
|
||||
|
||||
return assignments;
|
||||
}, [serializedIds, fileIds]);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePageDocument } from './usePageDocument';
|
||||
import { PDFDocument } from '@app/types/pageEditor';
|
||||
|
||||
/**
|
||||
* Hook that calls usePageDocument but only returns the FIRST non-null result
|
||||
* After initialization, it ignores all subsequent updates
|
||||
*/
|
||||
export function useInitialPageDocument(): PDFDocument | null {
|
||||
const { document: liveDocument } = usePageDocument();
|
||||
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Only set once when we get the first non-null document
|
||||
if (liveDocument && !initialDocument) {
|
||||
console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages');
|
||||
setInitialDocument(liveDocument);
|
||||
}
|
||||
}, [liveDocument, initialDocument]);
|
||||
|
||||
return initialDocument;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { usePageEditor } from '@app/contexts/PageEditorContext';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
@@ -15,14 +16,29 @@ export interface PageDocumentHook {
|
||||
*/
|
||||
export function usePageDocument(): PageDocumentHook {
|
||||
const { state, selectors } = useFileState();
|
||||
const { fileOrder } = usePageEditor();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
||||
// This ensures the page editor respects its own workspace ordering
|
||||
const allFileIds = fileOrder;
|
||||
|
||||
// Stable signature for effects (prevents loops)
|
||||
// Derive selected file IDs directly from FileContext (single source of truth)
|
||||
// Filter to only include PDF files (PageEditor only supports PDFs)
|
||||
// Use stable string keys to prevent infinite loops
|
||||
const allFileIdsKey = allFileIds.join(',');
|
||||
const selectedFileIdsKey = [...state.ui.selectedFileIds].sort().join(',');
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Get ALL PDF files (selected or not) for document building with placeholders
|
||||
const activeFileIds = useMemo(() => {
|
||||
return allFileIds.filter(id => {
|
||||
const stub = selectors.getStirlingFileStub(id);
|
||||
return stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
|
||||
});
|
||||
}, [allFileIdsKey, activeFilesSignature, selectors]);
|
||||
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
@@ -69,13 +85,28 @@ export function usePageDocument(): PageDocumentHook {
|
||||
// Build pages by interleaving original pages with insertions
|
||||
let pages: PDFPage[] = [];
|
||||
|
||||
// Helper function to create pages from a file
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||
// Helper function to create pages from a file (or placeholder if deselected)
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If file is deselected, create a single placeholder page
|
||||
if (!isSelected) {
|
||||
return [{
|
||||
id: `${fileId}-placeholder`,
|
||||
pageNumber: startPageNumber,
|
||||
originalPageNumber: 1,
|
||||
originalFileId: fileId,
|
||||
rotation: 0,
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: true,
|
||||
}];
|
||||
}
|
||||
|
||||
const processedFile = stirlingFileStub.processedFile;
|
||||
let filePages: PDFPage[] = [];
|
||||
|
||||
@@ -90,6 +121,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
splitAfter: page.splitAfter || false,
|
||||
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
} else if (processedFile?.totalPages) {
|
||||
// Fallback: create pages without thumbnails but with correct count
|
||||
@@ -102,16 +134,30 @@ export function usePageDocument(): PageDocumentHook {
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return filePages;
|
||||
};
|
||||
|
||||
// Collect all pages from original files (without renumbering yet)
|
||||
// Collect all pages from original files, respecting their previous positions
|
||||
const selectedFileIdsSet = new Set(state.ui.selectedFileIds);
|
||||
|
||||
// Sort original files by their position in fileOrder (so placeholders stay in correct spot)
|
||||
// Use fileOrder as source of truth since it persists across page editor sessions
|
||||
const fileOrderMap = new Map(allFileIds.map((id, index) => [id, index]));
|
||||
|
||||
const sortedOriginalFileIds = [...originalFileIds].sort((a, b) => {
|
||||
const posA = fileOrderMap.get(a) ?? Number.MAX_SAFE_INTEGER;
|
||||
const posB = fileOrderMap.get(b) ?? Number.MAX_SAFE_INTEGER;
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const originalFilePages: PDFPage[] = [];
|
||||
originalFileIds.forEach(fileId => {
|
||||
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
|
||||
sortedOriginalFileIds.forEach(fileId => {
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering
|
||||
originalFilePages.push(...filePages);
|
||||
});
|
||||
|
||||
@@ -130,7 +176,8 @@ export function usePageDocument(): PageDocumentHook {
|
||||
// Collect all pages to insert
|
||||
const allNewPages: PDFPage[] = [];
|
||||
fileIds.forEach(fileId => {
|
||||
const insertedPages = createPagesFromFile(fileId, 1);
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const insertedPages = createPagesFromFile(fileId, 1, isSelected);
|
||||
allNewPages.push(...insertedPages);
|
||||
});
|
||||
|
||||
@@ -147,6 +194,13 @@ export function usePageDocument(): PageDocumentHook {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pages are already in the correct order from the sorted assembly above
|
||||
// Just ensure page numbers are sequential
|
||||
pages = pages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1,
|
||||
}));
|
||||
|
||||
const mergedDoc: PDFDocument = {
|
||||
id: activeFileIds.join('-'),
|
||||
name,
|
||||
@@ -156,7 +210,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, allFileIds]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePageEditor } from '@app/contexts/PageEditorContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { useFileColorMap } from '@app/components/pageEditor/hooks/useFileColorMap';
|
||||
|
||||
export interface PageEditorDropdownFile {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
versionNumber?: number;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export interface PageEditorDropdownState {
|
||||
files: PageEditorDropdownFile[];
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
fileColorMap: Map<FileId, number>;
|
||||
}
|
||||
|
||||
const isPdf = (name?: string | null) =>
|
||||
typeof name === 'string' && name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
export function usePageEditorDropdownState(): PageEditorDropdownState {
|
||||
const { state, selectors } = useFileState();
|
||||
const {
|
||||
toggleFileSelection,
|
||||
reorderFiles,
|
||||
fileOrder,
|
||||
} = usePageEditor();
|
||||
|
||||
const pageEditorFiles = useMemo(() => {
|
||||
return fileOrder
|
||||
.map<PageEditorDropdownFile | null>((fileId) => {
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
if (!isPdf(stub?.name)) return null;
|
||||
|
||||
return {
|
||||
fileId,
|
||||
name: stub?.name || '',
|
||||
versionNumber: stub?.versionNumber,
|
||||
isSelected: state.ui.selectedFileIds.includes(fileId),
|
||||
};
|
||||
})
|
||||
.filter((file): file is PageEditorDropdownFile => file !== null);
|
||||
}, [fileOrder, selectors, state.ui.selectedFileIds]);
|
||||
|
||||
const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId));
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => pageEditorFiles.filter((file) => file.isSelected).length,
|
||||
[pageEditorFiles]
|
||||
);
|
||||
|
||||
return useMemo<PageEditorDropdownState>(() => ({
|
||||
files: pageEditorFiles,
|
||||
selectedCount,
|
||||
totalCount: pageEditorFiles.length,
|
||||
onToggleSelection: toggleFileSelection,
|
||||
onReorder: reorderFiles,
|
||||
fileColorMap,
|
||||
}), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface PageEditorRightRailButtonsParams {
|
||||
handleDeselectAll: () => void;
|
||||
handleDelete: () => void;
|
||||
onExportSelected: () => void;
|
||||
onSaveChanges: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
@@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
@@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
||||
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
||||
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
||||
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
||||
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
@@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
visible: totalPages > 0,
|
||||
onClick: onExportSelected,
|
||||
},
|
||||
{
|
||||
id: 'page-save-changes',
|
||||
icon: <LocalIcon icon="save" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: saveChangesLabel,
|
||||
ariaLabel: saveChangesLabel,
|
||||
section: 'top' as const,
|
||||
order: 55,
|
||||
disabled: totalPages === 0 || exportLoading,
|
||||
visible: totalPages > 0,
|
||||
onClick: onSaveChanges,
|
||||
},
|
||||
{
|
||||
id: 'page-close-pdf',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
selectByNumberLabel,
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
saveChangesLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
@@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
z-index: 100;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
@@ -44,10 +44,10 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
style={{ color: action.color || 'var(--right-rail-icon)' }}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
|
||||
328
frontend/src/core/components/shared/PageEditorFileDropdown.tsx
Normal file
328
frontend/src/core/components/shared/PageEditorFileDropdown.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
|
||||
import { LocalIcon } from '../shared/LocalIcon';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import FitText from './FitText';
|
||||
import { getFileColorWithOpacity } from '../pageEditor/fileColors';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
// Local interface for PageEditor file display
|
||||
interface PageEditorFile {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
versionNumber?: number;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface FileMenuItemProps {
|
||||
file: PageEditorFile;
|
||||
index: number;
|
||||
colorIndex: number;
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
const FileMenuItem: React.FC<FileMenuItemProps> = ({
|
||||
file,
|
||||
index,
|
||||
colorIndex,
|
||||
onToggleSelection,
|
||||
onReorder,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep latest values without re-registering DnD
|
||||
const indexRef = useRef(index);
|
||||
const fileIdRef = useRef(file.fileId);
|
||||
const dropPositionRef = useRef<'above' | 'below'>('below');
|
||||
useEffect(() => { indexRef.current = index; }, [index]);
|
||||
useEffect(() => { fileIdRef.current = file.fileId; }, [file.fileId]);
|
||||
useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]);
|
||||
|
||||
// NEW: keep latest onReorder without effect re-run
|
||||
const onReorderRef = useRef(onReorder);
|
||||
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
|
||||
|
||||
// Gesture guard for row click vs drag
|
||||
const movedRef = useRef(false);
|
||||
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
movedRef.current = false;
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const dx = e.clientX - startRef.current.x;
|
||||
const dy = e.clientY - startRef.current.y;
|
||||
if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
startRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = itemRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: fileIdRef.current,
|
||||
fromIndex: indexRef.current,
|
||||
}),
|
||||
onDragStart: () => setIsDragging((p) => (p ? p : true)),
|
||||
onDrop: () => setIsDragging((p) => (p ? false : p)),
|
||||
canDrag: () => true,
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: fileIdRef.current,
|
||||
toIndex: indexRef.current,
|
||||
}),
|
||||
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
|
||||
onDragLeave: () => {
|
||||
setIsDragOver((p) => (p ? false : p));
|
||||
setDropPosition('below');
|
||||
},
|
||||
onDrag: ({ source }) => {
|
||||
// Determine drop position based on cursor location
|
||||
const element = itemRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
|
||||
setDropPosition(clientY < midpoint ? 'below' : 'above');
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
setIsDragOver(false);
|
||||
const dropPos = dropPositionRef.current;
|
||||
setDropPosition('below');
|
||||
const sourceData = source.data as any;
|
||||
if (sourceData?.type === 'file-item') {
|
||||
const fromIndex = sourceData.fromIndex as number;
|
||||
let toIndex = indexRef.current;
|
||||
|
||||
// Adjust toIndex based on drop position
|
||||
// If dropping below and dragging from above, or dropping above and dragging from below
|
||||
if (dropPos === 'below' && fromIndex < toIndex) {
|
||||
// Dragging down, drop after target - no adjustment needed
|
||||
} else if (dropPos === 'above' && fromIndex > toIndex) {
|
||||
// Dragging up, drop before target - no adjustment needed
|
||||
} else if (dropPos === 'below' && fromIndex > toIndex) {
|
||||
// Dragging up but want below target
|
||||
toIndex = toIndex + 1;
|
||||
} else if (dropPos === 'above' && fromIndex < toIndex) {
|
||||
// Dragging down but want above target
|
||||
toIndex = toIndex - 1;
|
||||
}
|
||||
|
||||
if (fromIndex !== toIndex) {
|
||||
onReorderRef.current(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
try { dragCleanup(); } catch { /* cleanup */ }
|
||||
try { dropCleanup(); } catch { /* cleanup */ }
|
||||
};
|
||||
}, []); // NOTE: no `onReorder` here
|
||||
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
|
||||
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Drop indicator line */}
|
||||
{isDragOver && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
backgroundColor: 'rgb(59, 130, 246)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={itemRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (movedRef.current) return; // ignore click after drag
|
||||
onToggleSelection(file.fileId);
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 0.75rem',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
borderLeft: `6px solid ${fileColorBorder}`,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={file.isSelected}
|
||||
onChange={() => onToggleSelection(file.fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<FitText className="ph-no-capture" text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed" className="ph-no-capture">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageEditorFileDropdownProps {
|
||||
files: PageEditorFile[];
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
switchingTo?: string | null;
|
||||
viewOptionStyle: React.CSSProperties;
|
||||
fileColorMap: Map<string, number>;
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
files,
|
||||
onToggleSelection,
|
||||
onReorder,
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
fileColorMap,
|
||||
selectedCount,
|
||||
totalCount,
|
||||
}) => {
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="40rem">
|
||||
<Menu.Target>
|
||||
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
|
||||
)}
|
||||
<span className="ph-no-capture">{selectedCount}/{totalCount} files selected</span>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown className="ph-no-capture" style={{
|
||||
backgroundColor: 'var(--right-rail-bg)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{files.map((file, index) => {
|
||||
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
|
||||
|
||||
return (
|
||||
<FileMenuItem
|
||||
key={file.fileId}
|
||||
file={file}
|
||||
index={index}
|
||||
colorIndex={colorIndex}
|
||||
onToggleSelection={onToggleSelection}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add File Button */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 0.75rem',
|
||||
marginTop: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent',
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%' }}>
|
||||
<AddIcon fontSize="small" style={{ color: 'var(--mantine-color-text)' }} />
|
||||
<Text size="sm" fw={500} style={{ color: 'var(--mantine-color-text)' }} className="ph-no-capture">
|
||||
Add File
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -24,10 +24,10 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
size = 'xs',
|
||||
color = 'var(--mantine-color-blue-7)'
|
||||
}) => {
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const toolIds = toolChain.map(tool => tool.toolId);
|
||||
|
||||
const getToolName = (toolId: ToolId) => {
|
||||
|
||||
@@ -72,7 +72,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
|
||||
const sidebarContext = useSidebarContext();
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||
@@ -172,7 +172,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const related = e.relatedTarget as Node | null;
|
||||
|
||||
// Moving into the tooltip → keep open
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
if (related && related instanceof Node && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
|
||||
|
||||
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
|
||||
|
||||
const viewOptionStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
paddingTop: '0.3rem',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'center',
|
||||
padding: '2px 1rem',
|
||||
};
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
// Helper function to create view options for SegmentedControl
|
||||
const createViewOptions = (
|
||||
currentView: WorkbenchType,
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
pageEditorState?: PageEditorDropdownState,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
// Viewer dropdown logic
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showDropdown = isInViewer && hasMultipleFiles;
|
||||
const showViewerDropdown = isInViewer && hasMultipleFiles;
|
||||
|
||||
const viewerOption = {
|
||||
label: showDropdown ? (
|
||||
label: showViewerDropdown ? (
|
||||
<FileDropdownMenu
|
||||
displayName={displayName}
|
||||
displayName={viewerDisplayName}
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
@@ -50,29 +52,38 @@ const createViewOptions = (
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
<VisibilityIcon fontSize="medium" />
|
||||
)}
|
||||
<span className="ph-no-capture">{displayName}</span>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
};
|
||||
|
||||
// Page Editor dropdown logic
|
||||
const isInPageEditor = currentView === 'pageEditor';
|
||||
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
|
||||
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
|
||||
|
||||
const pageEditorOption = {
|
||||
label: (
|
||||
label: showPageEditorDropdown ? (
|
||||
<PageEditorFileDropdown
|
||||
files={pageEditorState!.files}
|
||||
onToggleSelection={pageEditorState!.onToggleSelection}
|
||||
onReorder={pageEditorState!.onReorder}
|
||||
switchingTo={switchingTo}
|
||||
viewOptionStyle={viewOptionStyle}
|
||||
fileColorMap={pageEditorState!.fileColorMap}
|
||||
selectedCount={pageEditorState!.selectedCount}
|
||||
totalCount={pageEditorState!.totalCount}
|
||||
/>
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
@@ -82,17 +93,7 @@ const createViewOptions = (
|
||||
const fileEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
)}
|
||||
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
|
||||
</div>
|
||||
),
|
||||
value: "fileEditor",
|
||||
@@ -110,9 +111,9 @@ const createViewOptions = (
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
view.icon || <PictureAsPdfIcon fontSize="medium" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
@@ -143,6 +144,8 @@ const TopControls = ({
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const pageEditorState = usePageEditorDropdownState();
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
@@ -165,12 +168,24 @@ const TopControls = ({
|
||||
});
|
||||
}, [setCurrentView]);
|
||||
|
||||
// Memoize view options to prevent SegmentedControl re-renders
|
||||
const viewOptions = useMemo(() => createViewOptions(
|
||||
currentView,
|
||||
switchingTo,
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
pageEditorState,
|
||||
customViews
|
||||
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<div className="flex justify-center">
|
||||
<SegmentedControl
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
data={viewOptions}
|
||||
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
@@ -183,18 +198,32 @@ const TopControls = ({
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2.6rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
height: '1.8rem',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderTop: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
outline: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
outlineOffset: '-1px',
|
||||
padding: '0 0',
|
||||
gap: '0',
|
||||
},
|
||||
control: {
|
||||
borderRadius: 9999,
|
||||
borderRadius: '0 0 16px 16px',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
},
|
||||
indicator: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
height: '100%',
|
||||
top: '0rem',
|
||||
margin: '0',
|
||||
border: 'none',
|
||||
},
|
||||
label: {
|
||||
paddingTop: '0rem',
|
||||
paddingTop: '0',
|
||||
paddingBottom: '0',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { type OverlayPdfsParameters, type OverlayMode } from '@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import styles from '@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css';
|
||||
|
||||
interface OverlayPdfsSettingsProps {
|
||||
@@ -36,8 +38,22 @@ export default function OverlayPdfsSettings({ parameters, onParameterChange, dis
|
||||
const handleOpenOverlayFilesModal = () => {
|
||||
if (disabled) return;
|
||||
openFilesModal({
|
||||
customHandler: (files: File[]) => {
|
||||
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
|
||||
customHandler: async (files: File[] | StirlingFileStub[], _insertAfterPage?: number, isFromStorage?: boolean) => {
|
||||
let resolvedFiles: File[] = [];
|
||||
|
||||
if (isFromStorage) {
|
||||
// Load actual File objects from storage
|
||||
for (const stub of files as StirlingFileStub[]) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (stirlingFile) {
|
||||
resolvedFiles.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resolvedFiles = files as File[];
|
||||
}
|
||||
|
||||
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...resolvedFiles]);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -175,4 +191,3 @@ export default function OverlayPdfsSettings({ parameters, onParameterChange, dis
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,20 +10,28 @@ export interface FilesToolStepProps {
|
||||
minFiles?: number;
|
||||
}
|
||||
|
||||
export function CreateFilesToolStep(props: FilesToolStepProps & {
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { createStep, ...stepProps } = props;
|
||||
|
||||
return createStep(t("files.title", "Files"), {
|
||||
isVisible: true,
|
||||
isCollapsed: stepProps.isCollapsed,
|
||||
onCollapsedClick: stepProps.onCollapsedClick
|
||||
}, (
|
||||
<FileStatusIndicator
|
||||
selectedFiles={stepProps.selectedFiles}
|
||||
minFiles={stepProps.minFiles}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
// Backwards compatibility wrapper
|
||||
export function createFilesToolStep(
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||
props: FilesToolStepProps
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createStep(t("files.title", "Files"), {
|
||||
isVisible: true,
|
||||
isCollapsed: props.isCollapsed,
|
||||
onCollapsedClick: props.onCollapsedClick
|
||||
}, (
|
||||
<FileStatusIndicator
|
||||
selectedFiles={props.selectedFiles}
|
||||
minFiles={props.minFiles}
|
||||
/>
|
||||
));
|
||||
return <CreateFilesToolStep createStep={createStep} {...props} />;
|
||||
}
|
||||
|
||||
@@ -103,21 +103,29 @@ function ReviewStepContent<TParams = unknown>({
|
||||
);
|
||||
}
|
||||
|
||||
export function createReviewToolStep<TParams = unknown>(
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||
props: ReviewToolStepProps<TParams>
|
||||
): React.ReactElement {
|
||||
export function CreateReviewToolStep<TParams = unknown>(props: ReviewToolStepProps<TParams> & {
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { createStep, ...stepProps } = props;
|
||||
|
||||
return createStep(
|
||||
t("review", "Review"),
|
||||
{
|
||||
isVisible: props.isVisible,
|
||||
isCollapsed: props.isCollapsed,
|
||||
onCollapsedClick: props.onCollapsedClick,
|
||||
isVisible: stepProps.isVisible,
|
||||
isCollapsed: stepProps.isCollapsed,
|
||||
onCollapsedClick: stepProps.onCollapsedClick,
|
||||
_excludeFromCount: true,
|
||||
_noPadding: true,
|
||||
},
|
||||
<ReviewStepContent operation={props.operation} onFileClick={props.onFileClick} onUndo={props.onUndo} />
|
||||
<ReviewStepContent operation={stepProps.operation} onFileClick={stepProps.onFileClick} onUndo={stepProps.onUndo} />
|
||||
);
|
||||
}
|
||||
|
||||
// Backwards compatibility wrapper
|
||||
export function createReviewToolStep<TParams = unknown>(
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||
props: ReviewToolStepProps<TParams>
|
||||
): React.ReactElement {
|
||||
return <CreateReviewToolStep createStep={createStep} {...props} />;
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ const ToolStep = ({
|
||||
alwaysShowTooltip = false,
|
||||
tooltip
|
||||
}: ToolStepProps) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const parent = useContext(ToolStepContext);
|
||||
|
||||
// Auto-detect if we should show numbers based on sibling count or force option
|
||||
@@ -93,6 +91,8 @@ const ToolStep = ({
|
||||
|
||||
const stepNumber = _stepNumber;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
import { SPLIT_METHODS, type SplitMethod } from '@app/constants/splitConstants';
|
||||
|
||||
export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent | null => {
|
||||
/**
|
||||
* Hook that returns tooltip content for ALL split methods
|
||||
* Can be called once and then looked up by method
|
||||
*/
|
||||
export const useSplitSettingsTips = (): Record<SplitMethod, TooltipContent> => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!method) return null;
|
||||
|
||||
const tooltipMap: Record<SplitMethod, TooltipContent> = {
|
||||
[SPLIT_METHODS.BY_PAGES]: {
|
||||
header: {
|
||||
@@ -130,5 +132,5 @@ export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent |
|
||||
}
|
||||
};
|
||||
|
||||
return tooltipMap[method];
|
||||
return tooltipMap;
|
||||
};
|
||||
@@ -2,11 +2,10 @@ import React, { createContext, useContext, useState, useCallback, useMemo } from
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
|
||||
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => void }) => void;
|
||||
closeFilesModal: () => void;
|
||||
onFileUpload: (files: File[]) => void;
|
||||
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
|
||||
@@ -22,9 +21,9 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
||||
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
|
||||
const [customHandler, setCustomHandler] = useState<((files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => void) | undefined>();
|
||||
|
||||
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => {
|
||||
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => void }) => {
|
||||
setInsertAfterPage(options?.insertAfterPage);
|
||||
setCustomHandler(() => options?.customHandler);
|
||||
setIsFilesModalOpen(true);
|
||||
@@ -50,22 +49,8 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
|
||||
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
||||
if (customHandler) {
|
||||
// Load the actual files from storage for custom handler
|
||||
try {
|
||||
const loadedFiles: File[] = [];
|
||||
for (const stub of stirlingFileStubs) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (stirlingFile) {
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
customHandler(loadedFiles, insertAfterPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load files for custom handler:', error);
|
||||
}
|
||||
// Pass stubs directly to custom handler with flag indicating they're from storage
|
||||
customHandler(stirlingFileStubs, insertAfterPage, true);
|
||||
} else {
|
||||
// Normal case - use addStirlingFileStubs to preserve metadata
|
||||
if (actions.addStirlingFileStubs) {
|
||||
|
||||
359
frontend/src/core/contexts/PageEditorContext.tsx
Normal file
359
frontend/src/core/contexts/PageEditorContext.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo } from 'react';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { useFileActions, useFileState } from '@app/contexts/FileContext';
|
||||
import { PDFPage } from '@app/types/pageEditor';
|
||||
import { MAX_PAGE_EDITOR_FILES } from '@app/components/pageEditor/fileColors';
|
||||
|
||||
// PageEditorFile is now defined locally in consuming components
|
||||
// Components should derive file list directly from FileContext
|
||||
|
||||
/**
|
||||
* Computes file order based on the position of each file's first page
|
||||
* @param pages - Current page order
|
||||
* @returns Array of FileIds in order based on first page positions
|
||||
*/
|
||||
function computeFileOrderFromPages(pages: PDFPage[]): FileId[] {
|
||||
// Find the first page for each file
|
||||
const fileFirstPagePositions = new Map<FileId, number>();
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
const fileId = page.originalFileId;
|
||||
if (!fileId) return;
|
||||
|
||||
if (!fileFirstPagePositions.has(fileId)) {
|
||||
fileFirstPagePositions.set(fileId, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort files by their first page position
|
||||
const fileOrder = Array.from(fileFirstPagePositions.entries())
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.map(entry => entry[0]);
|
||||
|
||||
return fileOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders pages based on file reordering while preserving interlacing and manual page order
|
||||
* @param currentPages - Current page order (may include manual reordering and interlacing)
|
||||
* @param fromIndex - Source file index in the file order
|
||||
* @param toIndex - Target file index in the file order
|
||||
* @param orderedFileIds - File IDs in their current order
|
||||
* @returns Reordered pages with updated page numbers
|
||||
*/
|
||||
function reorderPagesForFileMove(
|
||||
currentPages: PDFPage[],
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
orderedFileIds: FileId[]
|
||||
): PDFPage[] {
|
||||
// Get the file ID being moved
|
||||
const movedFileId = orderedFileIds[fromIndex];
|
||||
const targetFileId = orderedFileIds[toIndex];
|
||||
|
||||
// Extract pages belonging to the moved file (maintaining their relative order)
|
||||
const movedFilePages: PDFPage[] = [];
|
||||
const remainingPages: PDFPage[] = [];
|
||||
|
||||
currentPages.forEach(page => {
|
||||
if (page.originalFileId === movedFileId) {
|
||||
movedFilePages.push(page);
|
||||
} else {
|
||||
remainingPages.push(page);
|
||||
}
|
||||
});
|
||||
|
||||
// Find the insertion point based on the target file
|
||||
let insertionIndex = 0;
|
||||
|
||||
if (fromIndex < toIndex) {
|
||||
// Moving down: insert AFTER the last page of ANY file that should come before us
|
||||
// We need to find the last page belonging to any file at index <= toIndex in orderedFileIds
|
||||
const filesBeforeUs = new Set(orderedFileIds.slice(0, toIndex + 1));
|
||||
for (let i = remainingPages.length - 1; i >= 0; i--) {
|
||||
const pageFileId = remainingPages[i].originalFileId;
|
||||
if (pageFileId && filesBeforeUs.has(pageFileId)) {
|
||||
insertionIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Moving up: insert BEFORE the first page of target file
|
||||
for (let i = 0; i < remainingPages.length; i++) {
|
||||
if (remainingPages[i].originalFileId === targetFileId) {
|
||||
insertionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert moved pages at the calculated position
|
||||
const reorderedPages = [
|
||||
...remainingPages.slice(0, insertionIndex),
|
||||
...movedFilePages,
|
||||
...remainingPages.slice(insertionIndex)
|
||||
];
|
||||
|
||||
// Renumber all pages sequentially (clone to avoid mutation)
|
||||
return reorderedPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
}
|
||||
|
||||
interface PageEditorContextValue {
|
||||
// Current page order (updated by PageEditor, used for file reordering)
|
||||
currentPages: PDFPage[] | null;
|
||||
updateCurrentPages: (pages: PDFPage[] | null) => void;
|
||||
|
||||
// Reordered pages (when file reordering happens)
|
||||
reorderedPages: PDFPage[] | null;
|
||||
clearReorderedPages: () => void;
|
||||
|
||||
// Page editor's own file order (independent of FileContext global order)
|
||||
fileOrder: FileId[];
|
||||
setFileOrder: (order: FileId[]) => void;
|
||||
|
||||
// Set file selection (calls FileContext actions)
|
||||
setFileSelection: (fileId: FileId, selected: boolean) => void;
|
||||
|
||||
// Toggle file selection (calls FileContext actions)
|
||||
toggleFileSelection: (fileId: FileId) => void;
|
||||
|
||||
// Select/deselect all files (calls FileContext actions)
|
||||
selectAll: () => void;
|
||||
deselectAll: () => void;
|
||||
|
||||
// Reorder files (only affects page editor's local order)
|
||||
reorderFiles: (fromIndex: number, toIndex: number) => void;
|
||||
|
||||
// Update file order based on page positions (when pages are manually reordered)
|
||||
updateFileOrderFromPages: (pages: PDFPage[]) => void;
|
||||
}
|
||||
|
||||
const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined);
|
||||
|
||||
interface PageEditorProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PageEditorProvider({ children }: PageEditorProviderProps) {
|
||||
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
|
||||
const [reorderedPages, setReorderedPages] = useState<PDFPage[] | null>(null);
|
||||
|
||||
// Page editor's own file order (independent of FileContext)
|
||||
const [fileOrder, setFileOrder] = useState<FileId[]>([]);
|
||||
|
||||
// Read from FileContext (for file metadata only, not order)
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { state } = useFileState();
|
||||
|
||||
// Keep a ref to always read latest state in stable callbacks
|
||||
const stateRef = React.useRef(state);
|
||||
React.useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
// Track the previous FileContext order to detect actual changes
|
||||
const prevFileContextIdsRef = React.useRef<FileId[]>([]);
|
||||
|
||||
// Initialize fileOrder from FileContext when files change (add/remove only)
|
||||
React.useEffect(() => {
|
||||
const currentFileIds = state.files.ids;
|
||||
const prevFileIds = prevFileContextIdsRef.current;
|
||||
|
||||
// Only react to FileContext changes, not our own fileOrder changes
|
||||
const fileContextChanged =
|
||||
currentFileIds.length !== prevFileIds.length ||
|
||||
!currentFileIds.every((id, idx) => id === prevFileIds[idx]);
|
||||
|
||||
if (!fileContextChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevFileContextIdsRef.current = currentFileIds;
|
||||
|
||||
// Collect new file IDs outside the setState callback so we can clear them after
|
||||
let newFileIdsToProcess: FileId[] = [];
|
||||
|
||||
// Use functional setState to read latest fileOrder without depending on it
|
||||
setFileOrder(currentOrder => {
|
||||
// Identify new files
|
||||
const newFileIds = currentFileIds.filter(id => !currentOrder.includes(id));
|
||||
newFileIdsToProcess = newFileIds; // Store for cleanup
|
||||
|
||||
// Remove deleted files
|
||||
const validFileOrder = currentOrder.filter(id => currentFileIds.includes(id));
|
||||
|
||||
if (newFileIds.length === 0 && validFileOrder.length === currentOrder.length) {
|
||||
return currentOrder; // No changes needed
|
||||
}
|
||||
|
||||
// Always append new files to end
|
||||
// If files have insertAfterPageId, page-level insertion is handled by usePageDocument
|
||||
return [...validFileOrder, ...newFileIds];
|
||||
});
|
||||
|
||||
// Clear insertAfterPageId after a delay to allow usePageDocument to consume it first
|
||||
setTimeout(() => {
|
||||
newFileIdsToProcess.forEach(fileId => {
|
||||
const stub = state.files.byId[fileId];
|
||||
if (stub?.insertAfterPageId) {
|
||||
fileActions.updateStirlingFileStub(fileId, { insertAfterPageId: undefined });
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}, [state.files.ids, state.files.byId, fileActions]);
|
||||
|
||||
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
|
||||
setCurrentPages(pages);
|
||||
}, []);
|
||||
|
||||
const clearReorderedPages = useCallback(() => {
|
||||
setReorderedPages(null);
|
||||
}, []);
|
||||
|
||||
const setFileSelection = useCallback((fileId: FileId, selected: boolean) => {
|
||||
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||
const isAlreadySelected = currentSelection.includes(fileId);
|
||||
|
||||
// Check if we're trying to select when at limit
|
||||
if (selected && !isAlreadySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
|
||||
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update FileContext selection
|
||||
const newSelectedIds = selected
|
||||
? [...currentSelection, fileId]
|
||||
: currentSelection.filter(id => id !== fileId);
|
||||
|
||||
fileActions.setSelectedFiles(newSelectedIds);
|
||||
}, [fileActions]);
|
||||
|
||||
const toggleFileSelection = useCallback((fileId: FileId) => {
|
||||
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||
const isCurrentlySelected = currentSelection.includes(fileId);
|
||||
|
||||
// If toggling on and at limit, don't allow
|
||||
if (!isCurrentlySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
|
||||
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update FileContext selection
|
||||
const newSelectedIds = isCurrentlySelected
|
||||
? currentSelection.filter(id => id !== fileId)
|
||||
: [...currentSelection, fileId];
|
||||
|
||||
fileActions.setSelectedFiles(newSelectedIds);
|
||||
}, [fileActions]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
const allFileIds = stateRef.current.files.ids;
|
||||
|
||||
if (allFileIds.length > MAX_PAGE_EDITOR_FILES) {
|
||||
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`);
|
||||
fileActions.setSelectedFiles(allFileIds.slice(0, MAX_PAGE_EDITOR_FILES));
|
||||
} else {
|
||||
fileActions.setSelectedFiles(allFileIds);
|
||||
}
|
||||
}, [fileActions]);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
fileActions.setSelectedFiles([]);
|
||||
}, [fileActions]);
|
||||
|
||||
const reorderFiles = useCallback((fromIndex: number, toIndex: number) => {
|
||||
// Reorder local fileOrder array (page editor workspace only)
|
||||
const newOrder = [...fileOrder];
|
||||
const [movedFileId] = newOrder.splice(fromIndex, 1);
|
||||
newOrder.splice(toIndex, 0, movedFileId);
|
||||
setFileOrder(newOrder);
|
||||
|
||||
// If current pages available, reorder them based on file move
|
||||
if (currentPages && currentPages.length > 0 && fromIndex !== toIndex) {
|
||||
// Get the current file order from pages (files that have pages loaded)
|
||||
const currentFileOrder: FileId[] = [];
|
||||
const filesSeen = new Set<FileId>();
|
||||
currentPages.forEach(page => {
|
||||
const fileId = page.originalFileId;
|
||||
if (fileId && !filesSeen.has(fileId)) {
|
||||
filesSeen.add(fileId);
|
||||
currentFileOrder.push(fileId);
|
||||
}
|
||||
});
|
||||
|
||||
// Get the target file ID from the NEW order (after the move)
|
||||
// When moving down: we want to position after the file at toIndex-1 (file just before insertion)
|
||||
// When moving up: we want to position before the file at toIndex+1 (file just after insertion)
|
||||
const targetFileId = fromIndex < toIndex
|
||||
? newOrder[toIndex - 1] // Moving down: target is the file just before where we inserted
|
||||
: newOrder[toIndex + 1]; // Moving up: target is the file just after where we inserted
|
||||
|
||||
// Find their positions in the current page order (not the full file list)
|
||||
const pageOrderFromIndex = currentFileOrder.findIndex(id => id === movedFileId);
|
||||
const pageOrderToIndex = currentFileOrder.findIndex(id => id === targetFileId);
|
||||
|
||||
// Only reorder pages if both files have pages loaded
|
||||
if (pageOrderFromIndex >= 0 && pageOrderToIndex >= 0) {
|
||||
const reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder);
|
||||
setReorderedPages(reorderedPagesResult);
|
||||
}
|
||||
}
|
||||
}, [fileOrder, currentPages]);
|
||||
|
||||
const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => {
|
||||
if (!pages || pages.length === 0) return;
|
||||
|
||||
// Compute the new file order based on page positions
|
||||
const newFileOrder = computeFileOrderFromPages(pages);
|
||||
|
||||
if (newFileOrder.length > 0) {
|
||||
// Update local page editor file order (not FileContext)
|
||||
setFileOrder(newFileOrder);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const value: PageEditorContextValue = useMemo(() => ({
|
||||
currentPages,
|
||||
updateCurrentPages,
|
||||
reorderedPages,
|
||||
clearReorderedPages,
|
||||
fileOrder,
|
||||
setFileOrder,
|
||||
setFileSelection,
|
||||
toggleFileSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
reorderFiles,
|
||||
updateFileOrderFromPages,
|
||||
}), [
|
||||
currentPages,
|
||||
updateCurrentPages,
|
||||
reorderedPages,
|
||||
clearReorderedPages,
|
||||
fileOrder,
|
||||
setFileSelection,
|
||||
toggleFileSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
reorderFiles,
|
||||
updateFileOrderFromPages,
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageEditorContext.Provider value={value}>
|
||||
{children}
|
||||
</PageEditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageEditor() {
|
||||
const context = useContext(PageEditorContext);
|
||||
if (!context) {
|
||||
throw new Error('usePageEditor must be used within PageEditorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -34,14 +34,14 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
setPreferences(preferencesService.getAllPreferences());
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
preferences,
|
||||
updatePreference,
|
||||
resetPreferences,
|
||||
}), [preferences, updatePreference, resetPreferences]);
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider
|
||||
value={{
|
||||
preferences,
|
||||
updatePreference,
|
||||
resetPreferences,
|
||||
}}
|
||||
>
|
||||
<PreferencesContext.Provider value={value}>
|
||||
{children}
|
||||
</PreferencesContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, ReactNode, useRef, useMemo, useCallback } from 'react';
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||
|
||||
@@ -280,21 +280,21 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleThumbnailSidebar = () => {
|
||||
const toggleThumbnailSidebar = useCallback(() => {
|
||||
setIsThumbnailSidebarVisible(prev => !prev);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleAnnotationsVisibility = () => {
|
||||
const toggleAnnotationsVisibility = useCallback(() => {
|
||||
setIsAnnotationsVisible(prev => !prev);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setAnnotationMode = (enabled: boolean) => {
|
||||
const setAnnotationMode = useCallback((enabled: boolean) => {
|
||||
setIsAnnotationModeState(enabled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleAnnotationMode = () => {
|
||||
const toggleAnnotationMode = useCallback(() => {
|
||||
setIsAnnotationModeState(prev => !prev);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// State getters - read from bridge refs
|
||||
const getScrollState = (): ScrollState => {
|
||||
@@ -334,7 +334,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
};
|
||||
|
||||
// Action handlers - call APIs directly
|
||||
const scrollActions = {
|
||||
const scrollActions = useMemo(() => ({
|
||||
scrollToPage: (page: number) => {
|
||||
const api = bridgeRefs.current.scroll?.api;
|
||||
if (api?.scrollToPage) {
|
||||
@@ -366,9 +366,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
api.scrollToPage({ pageNumber: scrollState.totalPages });
|
||||
}
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const zoomActions = {
|
||||
const zoomActions = useMemo(() => ({
|
||||
zoomIn: () => {
|
||||
const api = bridgeRefs.current.zoom?.api;
|
||||
if (api?.zoomIn) {
|
||||
@@ -405,9 +405,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
api.requestZoom(level);
|
||||
}
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const panActions = {
|
||||
const panActions = useMemo(() => ({
|
||||
enablePan: () => {
|
||||
const api = bridgeRefs.current.pan?.api;
|
||||
if (api?.enable) {
|
||||
@@ -426,9 +426,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
api.toggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const selectionActions = {
|
||||
const selectionActions = useMemo(() => ({
|
||||
copyToClipboard: () => {
|
||||
const api = bridgeRefs.current.selection?.api;
|
||||
if (api?.copyToClipboard) {
|
||||
@@ -449,9 +449,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const spreadActions = {
|
||||
const spreadActions = useMemo(() => ({
|
||||
setSpreadMode: (mode: SpreadMode) => {
|
||||
const api = bridgeRefs.current.spread?.api;
|
||||
if (api?.setSpreadMode) {
|
||||
@@ -471,9 +471,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
api.toggleSpreadMode();
|
||||
}
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const rotationActions = {
|
||||
const rotationActions = useMemo(() => ({
|
||||
rotateForward: () => {
|
||||
const api = bridgeRefs.current.rotation?.api;
|
||||
if (api?.rotateForward) {
|
||||
@@ -499,9 +499,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const searchActions = {
|
||||
const searchActions = useMemo(() => ({
|
||||
search: async (query: string) => {
|
||||
const api = bridgeRefs.current.search?.api;
|
||||
if (api?.search) {
|
||||
@@ -526,9 +526,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
api.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const exportActions = {
|
||||
const exportActions = useMemo(() => ({
|
||||
download: () => {
|
||||
const api = bridgeRefs.current.export?.api;
|
||||
if (api?.download) {
|
||||
@@ -548,29 +548,29 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}), []);
|
||||
|
||||
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
|
||||
const registerImmediateZoomUpdate = useCallback((callback: (percent: number) => void) => {
|
||||
immediateZoomUpdateCallback.current = callback;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => {
|
||||
const registerImmediateScrollUpdate = useCallback((callback: (currentPage: number, totalPages: number) => void) => {
|
||||
immediateScrollUpdateCallback.current = callback;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => {
|
||||
const triggerImmediateScrollUpdate = useCallback((currentPage: number, totalPages: number) => {
|
||||
if (immediateScrollUpdateCallback.current) {
|
||||
immediateScrollUpdateCallback.current(currentPage, totalPages);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerImmediateZoomUpdate = (zoomPercent: number) => {
|
||||
const triggerImmediateZoomUpdate = useCallback((zoomPercent: number) => {
|
||||
if (immediateZoomUpdateCallback.current) {
|
||||
immediateZoomUpdateCallback.current(zoomPercent);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: ViewerContextType = {
|
||||
const value = useMemo<ViewerContextType>(() => ({
|
||||
// UI state
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
@@ -615,7 +615,20 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
|
||||
// Bridge registration
|
||||
registerBridge,
|
||||
};
|
||||
}), [
|
||||
isThumbnailSidebarVisible,
|
||||
isAnnotationsVisible,
|
||||
isAnnotationMode,
|
||||
activeFileIndex,
|
||||
scrollActions,
|
||||
zoomActions,
|
||||
panActions,
|
||||
selectionActions,
|
||||
spreadActions,
|
||||
rotationActions,
|
||||
searchActions,
|
||||
exportActions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ViewerContext.Provider value={value}>
|
||||
|
||||
@@ -75,20 +75,43 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
const { stirlingFileStubs } = action.payload;
|
||||
const newIds: FileId[] = [];
|
||||
const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
|
||||
let hasInsertionPosition = false;
|
||||
|
||||
stirlingFileStubs.forEach(record => {
|
||||
// Only add if not already present (dedupe by stable ID)
|
||||
if (!newById[record.id]) {
|
||||
newIds.push(record.id);
|
||||
|
||||
// Track if any file has an insertion position
|
||||
if (record.insertAfterPageId) {
|
||||
hasInsertionPosition = true;
|
||||
}
|
||||
|
||||
// Store record WITH insertAfterPageId temporarily
|
||||
// PageEditorContext will read it and clear it
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
// Determine final file order
|
||||
// NOTE: If files have insertAfterPageId, we just append to end
|
||||
// The page-level insertion is handled by usePageDocument
|
||||
const finalIds = [...state.files.ids, ...newIds];
|
||||
|
||||
// Auto-select inserted files
|
||||
const newSelectedFileIds = hasInsertionPosition
|
||||
? [...state.ui.selectedFileIds, ...newIds]
|
||||
: state.ui.selectedFileIds;
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...state.files.ids, ...newIds],
|
||||
ids: finalIds,
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: newSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -149,17 +172,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
|
||||
// Validate that all IDs exist in current state
|
||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||
// Reorder selected files by passed order
|
||||
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
||||
|
||||
// Don't touch selectedFileIds - it's just a reference list, order doesn't matter
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
ids: validIds
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,16 +34,17 @@ export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParam
|
||||
}, [parameters, config.validateFn]);
|
||||
|
||||
const endpointName = config.endpointName;
|
||||
let getEndpointName: () => string;
|
||||
if (typeof endpointName === "string") {
|
||||
getEndpointName = useCallback(() => {
|
||||
return endpointName;
|
||||
}, []);
|
||||
} else {
|
||||
getEndpointName = useCallback(() => {
|
||||
return endpointName(parameters);
|
||||
}, [parameters]);
|
||||
}
|
||||
const isStringEndpoint = typeof endpointName === "string";
|
||||
|
||||
const getEndpointNameString = useCallback(() => {
|
||||
return endpointName as string;
|
||||
}, [endpointName]);
|
||||
|
||||
const getEndpointNameFunction = useCallback(() => {
|
||||
return (endpointName as (params: T) => string)(parameters);
|
||||
}, [endpointName, parameters]);
|
||||
|
||||
const getEndpointName = isStringEndpoint ? getEndpointNameString : getEndpointNameFunction;
|
||||
|
||||
return {
|
||||
parameters,
|
||||
|
||||
@@ -21,11 +21,16 @@ const Split = (props: BaseToolProps) => {
|
||||
);
|
||||
|
||||
const methodTips = useSplitMethodTips();
|
||||
const settingsTips = useSplitSettingsTips(base.params.parameters.method);
|
||||
const allSettingsTips = useSplitSettingsTips();
|
||||
|
||||
// Get tooltip content for the currently selected method
|
||||
const settingsTips = base.params.parameters.method
|
||||
? allSettingsTips[base.params.parameters.method]
|
||||
: null;
|
||||
|
||||
// Get tooltip content for a specific method
|
||||
const getMethodTooltip = (option: MethodOption) => {
|
||||
const tooltipContent = useSplitSettingsTips(option.value);
|
||||
const tooltipContent = allSettingsTips[option.value];
|
||||
return tooltipContent?.tips || [];
|
||||
};
|
||||
|
||||
@@ -50,8 +55,7 @@ const Split = (props: BaseToolProps) => {
|
||||
{
|
||||
title: t("split.steps.chooseMethod", "Choose Method"),
|
||||
isCollapsed: !!base.params.parameters.method, // Collapse when method is selected
|
||||
onCollapsedClick: () => base.params.updateParameter('method', '')
|
||||
,
|
||||
onCollapsedClick: () => base.params.updateParameter('method', ''),
|
||||
tooltip: methodTips,
|
||||
content: (
|
||||
<CardSelector<SplitMethod, MethodOption>
|
||||
@@ -86,7 +90,7 @@ const Split = (props: BaseToolProps) => {
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: "Split Results",
|
||||
title: t("split.resultsTitle", "Split Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<<<<<<< HEAD:frontend/src/core/types/pageEditor.ts
|
||||
import { FileId } from '@app/types/file';
|
||||
=======
|
||||
import { FileId } from './file';
|
||||
import { PageBreakSettings } from '../components/pageEditor/commands/pageCommands';
|
||||
>>>>>>> feature/v2/selected-pageeditor:frontend/src/types/pageEditor.ts
|
||||
|
||||
export interface PDFPage {
|
||||
id: string;
|
||||
@@ -9,7 +14,9 @@ export interface PDFPage {
|
||||
selected: boolean;
|
||||
splitAfter?: boolean;
|
||||
isBlankPage?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
originalFileId?: FileId;
|
||||
pageBreakSettings?: PageBreakSettings;
|
||||
}
|
||||
|
||||
export interface PDFDocument {
|
||||
|
||||
Reference in New Issue
Block a user