Squashed commit of the following:

commit f1901a2e56
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 27 18:23:27 2025 +0000

    revert lint

commit 09b0fbefcd
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 27 15:47:38 2025 +0000

    Hide file names in posthog

commit 3497ccd7bd
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 27 12:45:31 2025 +0000

    remove page break settings modal

commit 5e27dc88f8
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 27 12:37:00 2025 +0000

    retain interleaving

commit b276eb5b68
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 27 11:32:31 2025 +0000

    Lint

commit aec1f97ff8
Author: Reece <reece@stirlingpdf.com>
Date:   Sat Oct 25 14:19:32 2025 +0100

    -

commit fbe2dc2958
Author: Reece <reece@stirlingpdf.com>
Date:   Sat Oct 25 13:06:10 2025 +0100

    Fixed file reordering placeholder

commit aaae81c68e
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 24 15:57:30 2025 +0100

    -

commit 3aa77819f2
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 24 15:54:30 2025 +0100

    -

commit 28dab07870
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 24 15:51:37 2025 +0100

    -

commit ed6199de61
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 24 15:51:29 2025 +0100

    lint and revert onboarding

commit 4d59ebfb2a
Author: 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

commit ea4f37cccf
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 24 15:06:21 2025 +0100

    Merge history change

commit c25131ae9b
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 24 14:48:14 2025 +0100

     lint

commit 25df9410cd
Merge: 494f92421 848ff9688
Author: 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

commit 494f92421f
Author: 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.

commit eef5dce849
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 24 11:33:31 2025 +0100

    Drag and drop improvements basic box select

commit ddefe81082
Author: 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

commit be037b727f
Author: Reece <reece@stirlingpdf.com>
Date:   Thu Oct 23 18:15:37 2025 +0100

    File reorder logic

commit 7a56f0504e
Author: 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

commit f7c9855489
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 21:45:00 2025 +0100

    glow scaling

commit 36a358f907
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 21:25:44 2025 +0100

    Visual tweaks

commit 0bcb1810d6
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 21:08:18 2025 +0100

    tweak

commit aee535214d
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 20:55:45 2025 +0100

    Pretty lights

commit 6d3154a7ae
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 20:44:48 2025 +0100

    Update top bar controls visually

commit 658ce2dab9
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 18:45:14 2025 +0100

    add file

commit 15df5cf168
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 18:05:55 2025 +0100

    -

commit 23d7f38100
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 17:24:16 2025 +0100

    lint

commit 472fc2939e
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 15:56:54 2025 +0100

    lint 2

commit a21047e8b0
Merge: 8ee03fa1c 3e23dc59b
Author: 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

commit 8ee03fa1c6
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 15:50:14 2025 +0100

    Lint

commit a22913e1e4
Author: Reece <reece@stirlingpdf.com>
Date:   Mon Oct 20 14:16:41 2025 +0100

    page editor fixes post merge

commit b3c0c69a7c
Merge: 2289080f9 3e6236d95
Author: 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

commit 2289080f9c
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 17 16:55:29 2025 +0100

    remove buttons

commit a5ec62fa08
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 17 15:24:05 2025 +0100

    Performance improvements

commit e7f7b7e201
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 17 14:15:21 2025 +0100

    improved

commit 74e8388bce
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 15 21:33:54 2025 +0100

    Working mostly

commit e7c6db082c
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 15 16:31:30 2025 +0100

    Rejig arrays

commit 05a7161412
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 15 00:01:30 2025 +0100

    Structural tweaks

commit 39267e795c
Author: Reece <reece@stirlingpdf.com>
Date:   Tue Oct 14 12:41:50 2025 +0100

    Reworked page editor - dirty commit

commit 6acce968a5
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 19:32:41 2025 +0100

    fix 2

commit 0722ecc6c4
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 19:27:15 2025 +0100

    fix

commit 3597a8b7bd
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 19:16:04 2025 +0100

    Initial set up

commit c260394b95
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 17:15:07 2025 +0100

    Cleanup

commit 93fcfb280a
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 17:09:36 2025 +0100

    Remove logs tweak visuals, use fit text component

commit 69cb8e7aec
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 16:54:05 2025 +0100

    Fix signwith tab based system

commit 8e8e06628e
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 15:57:41 2025 +0100

    Nav based file select

commit 5d3710260f
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 13:37:52 2025 +0100

    Lint

commit ad8789d82a
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 13:35:18 2025 +0100

    remove file that came from nowhere

commit 749966a197
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 13:30:15 2025 +0100

    Remove mantine theme

commit d9e429aa3a
Merge: ad0b6cf2d b695e3900
Author: 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

commit ad0b6cf2d6
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Oct 10 12:55:03 2025 +0100

    Viewer tabs, embed update and layout fixes

commit b63f2c16a2
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 8 15:12:39 2025 +0100

    Remove unused legacy text signing Linting errors

commit edcc788d1a
Merge: 5b47ab5bb fdba336c0
Author: 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

commit 5b47ab5bbf
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 8 15:02:33 2025 +0100

    Remove debug logs

commit fdba336c05
Author: 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>

commit 5db6b85fb9
Merge: 70d941a40 13e88943b
Author: 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

commit 70d941a400
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 8 14:55:43 2025 +0100

    translations

commit 13e88943b7
Author: 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>

commit 339e5cfb65
Author: 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>

commit 10944d9d57
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 8 14:45:39 2025 +0100

    Remove debug logging

commit 0c9f460fb6
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 8 14:02:43 2025 +0100

    Remove arbitrary timers

commit fa6e01b46e
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Oct 8 12:26:34 2025 +0100

    Clean up

commit 23f85d7267
Author: Reece <reece@stirlingpdf.com>
Date:   Tue Oct 7 22:40:58 2025 +0100

    tweaks

commit f6290c0238
Author: Reece <reece@stirlingpdf.com>
Date:   Tue Oct 7 21:52:40 2025 +0100

    - Refactored signature saving process

commit 991be9ffa2
Author: 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

commit 07bf79f3ee
Author: Reece <reece@stirlingpdf.com>
Date:   Tue Oct 7 14:54:14 2025 +0100

    Improved canvas mode with signaturepad.js

commit 3a0acd0a21
Author: Reece <reece@stirlingpdf.com>
Date:   Tue Oct 7 12:56:12 2025 +0100

    Single canvas

commit fff637286f
Author: 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>

commit 8f94c8f57e
Merge: 708a296f8 2a29bda34
Author: 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

commit 708a296f8d
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 19:14:40 2025 +0100

    Auto update canvas signature

commit b486d1270e
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 19:03:24 2025 +0100

    Fix flicker on apply

commit 80faf0bc1e
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 18:55:09 2025 +0100

    -

commit 6555a9554a
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 18:53:16 2025 +0100

    Fix even more linting errors (Thanks James)

commit fdee719c89
Merge: 1be48c276 fd9fb9b97
Author: 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

commit 1be48c276b
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 18:12:13 2025 +0100

    fix text infinite loop

commit 2b6b7a8e1d
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 18:04:01 2025 +0100

    better error handling and killing logs

commit fd9fb9b972
Author: 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>

commit d8d6197008
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 17:48:41 2025 +0100

    fix page count issue

commit 1edd133e09
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 17:31:44 2025 +0100

    license checker use commonJS

commit 8685bf2a7c
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 17:26:19 2025 +0100

    gap

commit 36475069de
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 17:23:16 2025 +0100

    lint fix

commit 3aa8572c9e
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 17:16:17 2025 +0100

    Fix suggestions

commit 2e2d8477b9
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 17:01:06 2025 +0100

    Clean up

commit 90880eddf9
Author: 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>

commit 31fd6886dc
Merge: 3fdbf425b abc0988fd
Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date:   Fri Sep 26 16:40:34 2025 +0100

    Merge branch 'V2' into feature/v2/sign

commit 3fdbf425b4
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 16:39:38 2025 +0100

    Fix lintineg errors

commit 50e60d4972
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 16:27:52 2025 +0100

    Simple export block

commit a22330ebf4
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 16:09:20 2025 +0100

    Only flatten current annotations

commit 172f622c5f
Merge: cfd00b2c7 d82b958d9
Author: 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

commit cfd00b2c71
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 15:10:47 2025 +0100

    Render signature to pdf

commit d82b958d9f
Merge: c94ee388f 0bdc6466c
Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date:   Fri Sep 26 12:53:54 2025 +0100

    Merge branch 'V2' into feature/v2/sign

commit c94ee388fc
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 12:47:32 2025 +0100

    Restructure and bug fix

commit aa5333dcd9
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 03:23:59 2025 +0100

    Change to button based placement to avoid performance issue on canvas

commit a8265efff4
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 03:19:05 2025 +0100

    Improved performance

commit b9b425aba0
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 02:18:47 2025 +0100

    Fix undo/redo

commit 51caad636c
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 01:51:17 2025 +0100

    Reduce logs

commit 023fd43b72
Author: Reece <reece@stirlingpdf.com>
Date:   Fri Sep 26 01:49:33 2025 +0100

    Save file

commit a8a0808274
Author: Reece <reece@stirlingpdf.com>
Date:   Thu Sep 25 09:46:20 2025 +0100

    history tweaks

commit 3d2607f72a
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Sep 24 19:01:36 2025 +0100

    fixes

commit f9542a9257
Merge: a12e45757 963787316
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Sep 24 18:35:16 2025 +0100

    Merge branch 'feature/v2/exportpdf' into feature/v2/sign

commit 963787316a
Author: Reece <reece@stirlingpdf.com>
Date:   Wed Sep 24 17:42:58 2025 +0100

    Export with embedpdf

commit a12e457577
Author: 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.

commit bac61c7e9e
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Tue Sep 23 18:16:21 2025 +0100

    Delete signature

commit fc2f34ee15
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Tue Sep 23 17:18:39 2025 +0100

    fix add image

commit d9798badae
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Tue Sep 23 14:06:41 2025 +0100

    Fix sidebar refresh. Updated UI

commit efc0c1aab3
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Tue Sep 23 12:24:58 2025 +0100

    text and improved drawing

commit 10672403c9
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 22 14:14:35 2025 +0100

    Colours on document draw + translations

commit 32fed96aa7
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 22 14:03:49 2025 +0100

    Canvas and dosument draw split, drawing improvements

commit a70472b172
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Sat Sep 20 01:59:04 2025 +0100

    Initial set up

commit 3b87ca0c3c
Merge: 0e1da982b 6172351ee
Author: 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

commit 0e1da982b6
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 19 11:38:53 2025 +0100

    Fix vite

commit 6172351eed
Merge: 1174b6a4d ae7be50ec
Author: 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

commit 1174b6a4da
Merge: a970c44d0 21a2433dd
Author: 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

commit a970c44d03
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 19 11:14:58 2025 +0100

    improvements

commit b574cef54a
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 19 10:48:29 2025 +0100

    improvements

commit 21a2433dd8
Author: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date:   Thu Sep 18 13:14:44 2025 +0100

    Remove marginTop style from Workbench component

commit 07cc250176
Author: 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.

commit dc71b3007b
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 18 12:32:42 2025 +0100

    clean up

commit 1598057ed0
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 18 08:44:57 2025 +0100

    Tweaks

commit 312fc2d615
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 18 02:14:31 2025 +0100

    Clean up

commit 72375d89d1
Merge: a990ecc02 7ff1c66d0
Author: 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

commit a990ecc02a
Merge: da6ecc661 b51c2e42a
Author: 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

commit da6ecc6619
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Wed Sep 17 14:35:44 2025 +0100

    Fix scroll page identification

commit dac176f0c6
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Wed Sep 17 12:07:44 2025 +0100

    Fix colours

commit 41e5a7fbd6
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Wed Sep 17 12:00:20 2025 +0100

    Restructure to avoid global variables
    fix zoom

commit b81ed9ec2e
Merge: 9b5c50db0 81c5d8ff4
Author: 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

commit 9b5c50db07
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Tue Sep 16 19:36:36 2025 +0100

    Improved Structure with context at root

commit 81c5d8ff46
Author: James Brunton <james@stirlingpdf.com>
Date:   Tue Sep 16 16:06:40 2025 +0100

    Potential fix for mime type issues

commit a67f5199d3
Author: James Brunton <james@stirlingpdf.com>
Date:   Tue Sep 16 16:06:27 2025 +0100

    Improvements for scroll gestures

commit 3755bfde34
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 15 18:20:11 2025 +0100

    Set zoom to 140%

commit 2834eec3be
Merge: 19d7111ca d89e1b5b1
Author: 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

commit d89e1b5b1e
Merge: 5d7fb638a a57373b96
Author: 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

commit 19d7111cab
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 15 17:27:22 2025 +0100

    Remove unused code

commit ca9d7ef465
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 15 17:03:52 2025 +0100

    Remove unused code

commit fad4f84c9c
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 15 16:53:41 2025 +0100

    translations

commit 35863ac610
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 15 16:53:32 2025 +0100

    remove select mode

commit c17dd25069
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 15 16:05:19 2025 +0100

    Rotate

commit 5d7fb638af
Merge: 2fb4710dd 7dad484aa
Author: 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

commit 2fb4710dd7
Merge: 85a74c1d4 cfdb6eaa1
Author: 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

commit 85a74c1d46
Merge: 21a93d6ca 9599bca8a
Author: 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

commit 21a93d6cac
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Mon Sep 15 13:33:39 2025 +0100

    Context based right rail controls for viewer

commit 9599bca8a9
Author: 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>

commit 1709ca9049
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 12 16:38:29 2025 +0100

    Rems

commit 18e4e03220
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 12 16:26:05 2025 +0100

    rename APIBridge

commit 9901771572
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 12 16:19:07 2025 +0100

    improve search

commit 514956570c
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 12 15:06:06 2025 +0100

    pan state improvements

commit 423617db52
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 12 14:21:31 2025 +0100

    thumbnail sidebar

commit 143f0c5031
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 12 01:56:51 2025 +0100

    search pdf

commit 368e9801a1
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Fri Sep 12 00:35:27 2025 +0100

    Zoom with wheel and +/-

commit afc9ca5858
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 11 23:52:38 2025 +0100

    spread/multipage

commit 8815575124
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 11 22:51:10 2025 +0100

    pan

commit fb9b01f53b
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 11 20:07:43 2025 +0100

    improved scaling and fix grey void

commit 93607937f6
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 11 19:38:04 2025 +0100

    selection also

commit 687ab39286
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 11 19:36:44 2025 +0100

    Text selection

commit 83a3222cf6
Author: Reece Browne <reecebrowne1995@gmail.com>
Date:   Thu Sep 11 19:08:44 2025 +0100

    Set up
This commit is contained in:
Reece
2025-10-28 14:39:11 +00:00
parent d2b38ef4b8
commit 7de8347472
34 changed files with 2566 additions and 549 deletions

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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"
>

View File

@@ -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,

View File

@@ -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 = [];

View 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})`);
}

View File

@@ -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]);
}

View File

@@ -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;
}

View File

@@ -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(() => {

View File

@@ -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]);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View 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>
);
};

View File

@@ -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) => {

View File

@@ -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;
}

View File

@@ -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',
}
}}
/>

View File

@@ -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
);
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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) {

View 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;
}

View File

@@ -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>
);

View File

@@ -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}>

View File

@@ -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,
}
};
}

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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 {