Compare commits

...
Author SHA1 Message Date
EthanHealy01 e2e37695cd fix: use ?? instead of || so empty thumbnail string skips generation 2026-05-05 17:50:09 +01:00
EthanHealy01 fa3e9f5db6 fix: remove unreachable size check inside image branch of generateThumbnailForFile 2026-05-05 17:49:18 +01:00
EthanHealy01 2c7efe1e83 refactor: consolidate thumbnail rendering onto UIRedesignStaging base
Apply useFileThumbnail + DocumentThumbnail to FileEditorThumbnail, rebased
onto UIRedesignStaging. Also carry forward the FileEditorFileName truncation
fix from main (#6310).
2026-05-05 17:31:04 +01:00
EthanHealy01 18fd86f5bf These adress the change requests along with some other issues I found with our UI 2026-05-05 17:25:31 +01:00
EthanHealy01 93ab2b4805 merge main 2026-05-05 11:54:46 +01:00
EthanHealy01 54805896d6 Merge remote-tracking branch 'origin/main' into UIRedesignStaging 2026-05-01 18:54:21 +01:00
EthanHealy01 0a73467ce5 Revert "fix: viewer stays on signed file instead of jumping to file A after tool run"
This reverts commit df056a8f2d.
2026-05-01 14:35:11 +01:00
EthanHealy01 df056a8f2d fix: viewer stays on signed file instead of jumping to file A after tool run
Remove the useEffect that cleared activeFileId when its file left the workbench.
That effect raced with tool operations: CONSUME_FILES atomically removes the input
and adds the output in one dispatch, but the clearing effect fired between the
dispatch and the tool's setActiveFileId(newId) call, clobbering the correct new ID
with null. activeFileIndex already falls back to 0 for stale/null IDs, so the
clearing effect was not needed for correctness.
2026-05-01 14:32:48 +01:00
EthanHealy01 34d9baa1be fix: move close icon from hamburger to search icon when search is active 2026-05-01 14:23:15 +01:00
EthanHealy01 d86259c596 Merge branch 'main' into UIRedesignStaging 2026-05-01 14:13:58 +01:00
EthanHealy01 178c3c3331 prettier 2026-05-01 14:04:33 +01:00
EthanHealy01 39e0fb630c fix: stay on multi-tool after first file upload
getStartupNavigationAction was unconditionally redirecting to viewer/
fileEditor on 0→N file transitions, overriding the multi-tool workbench.
Exempt multiTool the same way pdfTextEditor already is.
2026-05-01 12:17:14 +01:00
EthanHealy01 ed7a9d1a01 Merge branch 'main' into UIRedesignStaging 2026-05-01 12:10:38 +01:00
EthanHealy01 be69d071bc test(e2e): fix Firefox toggle click in settings spec
Click the visible label wrapper instead of force-clicking the hidden
checkbox input, which Firefox does not register as a state change.
2026-05-01 12:07:55 +01:00
EthanHealy01 a8671ec1f7 test(e2e): fix all stubbed Playwright test failures
Updated stubbed specs to match the redesigned UI: fixed locators for
WorkbenchBar buttons, FileSidebar search, navigation, settings dialog,
language selector, and in-document PDF search. All 130 stubbed tests
now pass (7 intentional skips for build-conditional features).
2026-05-01 10:55:19 +01:00
EthanHealy01 9cb40ad6ff Add missing workbenchBar.multiTool translation key 2026-04-30 21:31:07 +01:00
EthanHealy01 67fc78b6df Fix lint, formatting, and TOML parse errors post-merge
- Remove unused meta variable and getFileTypeMeta import from NonPdfViewer
- Fix TOML: move compare.edited.selectBaseFirst and compare.swap.label from
  dotted-key form in [compare] to their proper subsections
- Run Prettier on 7 files flagged by format check
2026-04-30 21:29:11 +01:00
EthanHealy01 b5b4bbc46b Merge branch 'main' into UIRedesignStaging
Resolved conflict in en-GB/translation.toml:
- Accept main's reorganised/alphabetised structure
- Restore UIRedesign wording (checked/add vs selected/select terminology)
- Add sections new to UIRedesign: fileSelectorPicker, fileSidebar, workbenchBar
- Add missing keys: rightRail.closeAll/share, tool.filesLoading/filesLoadingProgress,
  settings.general theme/language keys, fileEditor.pageCount, encryptedPdfUnlock viewer keys
2026-04-30 21:24:27 +01:00
EthanHealy01 849899f58c Merge remote-tracking branch 'origin/UIRedesignStaging' into UIRedesignStaging 2026-04-30 21:17:08 +01:00
EthanHealy01 a42fdfad00 UI redesign: multi-tool mode, workbench bar animation, PDFium rendering fixes
- Add multi-tool mode support across FileSidebar, FilesModalContext, PageEditorContext,
  WorkbenchBar, and ToolWorkflowContext — adding files stays in pageEditor view and
  auto-selects files; left panel hides/shows on workbench switch
- Animate WorkbenchBar in/out via CSS grid-template-rows trick when files are added/removed;
  suppress initial transition with rAF flag
- Fix PDFium pixel format (RGBA not BGRA) and rotation logic (effective dims already
  post-rotation; counter-rotate when applyRotation=false)
- Fix PageEditor flash of "No PDF files loaded" by checking file context directly
- Redesign AddFileCard: solid border, rounded corners, shadow hover, focus-visible ring
- Show AddFileCard in FileEditor empty state instead of text stack
- Add date grouping helpers (today/yesterday/thisWeek/thisMonth/older) to FileSidebar
- Redesign FilePdfIcon: solid filled with folded corner, red colour
- Simplify NonPdfBanner: remove file-type badge, keep only convert-to-PDF button
- Fix thumbnail precedence in useToolOperation (prefer metadata.thumbnailUrl)
- Fix fileActions stub dispatch to trigger selector re-render after ADD_FILES
- Switch Annotate to useAllFiles and Automate/AutomationRun to useViewScopedFiles
2026-04-30 21:16:58 +01:00
EthanHealy01 770577edb6 Merge branch 'main' into UIRedesignStaging 2026-04-24 11:00:53 +01:00
EthanHealy01 eaed8feb42 aikido change requests 2026-04-23 18:59:36 +01:00
EthanHealy01 a0f294e68e Merge branch 'main' into UIRedesignStaging 2026-04-23 14:36:38 +01:00
EthanHealy01 3b24b3479a Merge branch 'main' into UIRedesignStaging 2026-04-23 13:05:14 +01:00
EthanHealy01 fd1cf4468e prettier 2026-04-21 22:38:27 +01:00
EthanHealy01 04c5fbc590 change stuff for playwright test and remove test for drag and drop compare upload which no longer exists 2026-04-21 21:26:19 +01:00
EthanHealy01 d2f28e07ea Merge branch 'main' into UIRedesignStaging 2026-04-21 18:50:19 +01:00
EthanHealy01 2843418fb7 frontend validatoin 2026-04-21 18:47:23 +01:00
EthanHealy01 53377da75d test 2026-04-21 18:09:22 +01:00
EthanHealy01 93b1669f63 Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into UIRedesignStaging 2026-04-21 16:35:51 +01:00
EthanHealy01 7dc164453b UIUX/UI redesign (#6107)
# PR: feature/UIRedesign → UIRedesignStaging

## Overview

Major layout restructure: the old right-rail icon column and top
file-tab strip are replaced by a new horizontal `WorkbenchBar` and a new
persistent `FileSidebar` on the left. Alongside those headline changes,
a range of bugs are fixed around viewer state, file management, and
thumbnail caching.

---

## Changes

### 1. Page layout restructured

**Before:** `QuickAccessBar` above content, `ToolPanel` on the left,
`RightRail` vertical icon column on the right.

**After:** `FileSidebar` on the left, `WorkbenchBar` across the top,
`ToolPanel` on the right. The right rail is gone.

---

### 2. `FileSidebar` — new persistent left sidebar

**Why:** The only way to see saved files was opening the file manager
modal. The sidebar gives quick access to everything in IndexedDB without
a modal.

**Before:** No persistent sidebar. Files managed exclusively via the
modal.

**After:**
- Collapsible strip on the left (collapses to a narrow icon strip,
expands to a file list).
- Shows all saved files, sorted by last modified. Files not yet
persisted to IndexedDB also appear.
- Click a file to toggle it on/off the workbench. If the file is open in
the viewer with unsaved changes, the save prompt fires first.
- Search bar filters by filename.
- Add files from computer or Google Drive directly from the sidebar.
- Thumbnail appears on hover, loaded lazily and cached for future
visits.
- The currently-viewed file is pinned at the top of the list with a
green highlight.
- List updates automatically when files are added or deleted anywhere in
the app.

---

### 3. `WorkbenchBar` replaces `TopControls` + `RightRail`

**Why:** `TopControls` only showed when files existed and only displayed
file tabs. The right rail was a separate vertical column. Neither gave a
coherent home for view switching, tool actions, and file operations.

**Before:** File tabs in a strip at the top; download/close/view buttons
in a separate vertical column on the right.

**After:**
- Always visible bar across the top.
- Left: Viewer / Active Files view switcher (hidden when no files are
open).
- Centre: Tool-specific action buttons (the same buttons that previously
lived in the right rail), plus zoom controls when in viewer mode.
- Right: Print (viewer only), Close (closes the current file in viewer
mode, closes all files in file editor mode), Download, Save As.
- Adapts to narrow widths by wrapping into two rows.


---

### 4. Viewer — active file never loses track after tool operations

**Why:** After running a tool on a file, the viewer would sometimes jump
to showing a different file.

**Before:** The "active file index" and "active file ID" were tracked as
two separate pieces of state that could desync when files were reordered
after a tool ran.

**After:** The index is always derived from the ID, so they can never
disagree. The viewer stays on the correct file after any operation.


### 5. Auto-navigate after file upload

**Before:** After uploading files via the modal, the user was left on
whatever view was active — no automatic navigation.

**After:** Single file uploaded → navigates to viewer. Multiple files
uploaded → navigates to Active Files.

---

### 6. Run button shows loading progress while files are still loading

**Why:** Opening a tool immediately after uploading many files could
silently skip files that hadn't finished loading yet.

**Before:** Run button was enabled immediately; files still being loaded
from storage were silently skipped.

**After:** While files are loading, the run button is replaced with a
disabled progress indicator. For multiple pending files it shows "2 / 5
files loading…". Once all files are ready, the button becomes active.

---

### 7. Bulk delete in file manager

**Why:** Deleting many files at once was slow and caused visible flicker
as each file was removed one by one.

**Before:** Each selected file went through the full single-delete flow
sequentially.

**After:** Local files are deleted in a single batch operation — one UI
update, one IndexedDB transaction. Server-connected files still go
through the sequential flow since each may need a confirmation prompt.

---

### 8. Deselecting a dirty file in the modal no longer removes it from
the workbench

**Before:** Deselecting a file in the file manager and clicking "Open"
would remove it from the workbench even if it had unsaved changes.

**After:** Files with unsaved changes are skipped when removing
deselected files.

---

### 9. File manager — "Close all files" button

**Before:** With nothing selected in the file manager, the "Open File"
button was disabled.

**After:** When nothing is selected but files are open, the button
changes to "Close all files" and becomes enabled.

---

### 10. File picker — search and hover thumbnails

**Before:** No way to filter the file list in the picker dropdown. No
visual preview.

**After:** Search input filters files by name. Hovering a row shows a
thumbnail preview.

---

### 11. Pan and ruler modes are now mutually exclusive

**Before:** Enabling the pan tool while the ruler was active left the
ruler in a broken state, and vice versa.

**After:** Activating either tool automatically deactivates the other.

---

### 12. Tool icons switched to outline style

All tool icons in the picker are now the outline variant instead of the
filled/solid variant, for a lighter, more consistent look.

---

### 13. Settings — language and theme options added to General settings

Language and theme (light/dark) controls are now available in Settings →
General.

---

## Test Script

---

### Sidebar and file management

1. **Sidebar toggle:** Confirm the left sidebar strip is visible on
load. Click expand — confirm the file list appears. Click collapse —
confirm it shrinks back to the strip.
2. **Upload and see files:** Upload 3 PDFs. Confirm all three appear in
the sidebar list without needing to open any modal.
3. **Sidebar search:** Type part of a filename in the sidebar search
box. Confirm the list filters. Clear it — confirm all files return.
4. **Toggle files on workbench from sidebar:** Click a file in the
sidebar that isn't on the workbench — confirm it opens. Click it again —
confirm it closes.
5. **Save prompt via sidebar:** Open a file in the viewer and make a
change (e.g. annotate). Click that file in the sidebar to close it —
confirm the unsaved-changes save prompt appears.
6. **Thumbnail on hover:** Hover a file row in the sidebar — confirm a
thumbnail preview appears.
7. **Currently viewed file pinned:** With multiple files open, confirm
the one you're viewing is highlighted at the top of the sidebar list.
8. **Sidebar reflects deletions:** Delete a file via the file manager
modal. Confirm the sidebar updates automatically without a page reload.

---

### Workbench bar

9. **No files state:** With no files open, confirm the workbench bar is
visible but no view tabs are shown.
10. **View tabs appear:** Upload a file — confirm "Viewer" and "Active
Files" tabs appear. Switch between them.
11. **Close button behaviour:** In Active Files with multiple files,
click Close — confirm all files close. Open a single file in viewer
mode, click Close — confirm only that file closes.
12. **Download:** Open a file in the viewer, click Download — confirm
the file saves.
13. **Zoom controls in viewer:** Confirm zoom in/out and a slider appear
in the bar's centre when in viewer mode. Drag the slider — confirm the
PDF zooms live. Switch to Active Files — confirm the controls disappear.

---

### Tool panel

14. **Collapse/expand:** Collapse the tool panel. Confirm a narrow strip
remains (not blank space). Click the expand icon — panel opens. Collapse
again, click the search icon — confirm the panel opens with the search
field already focused.
15. **Back button in tool:** Open a tool (e.g. Compress). Confirm the
search bar icon changes to a back arrow. Click it — confirm you return
to the tool list.
16. **Viewer tool mini bar:** Open a PDF in the viewer. Confirm a mini
toolbar appears at the top of the panel with viewer-specific buttons
(annotate, redact, form fill, etc.). Switch to Active Files — confirm it
disappears.

---

### Running tools with files loading

17. **Loading indicator on run button:** Upload 5+ PDFs and immediately
open a tool (e.g. Compress or Merge). If files are still loading,
confirm the run button shows a loading progress indicator and is
disabled. Wait until loading finishes — confirm the button becomes
active.

---

### Viewer correctness

18. **Active file stays put after tool run:** Open 3 PDFs. Navigate to
file #2 in the viewer. Run a tool (e.g. rotate) on file #2. Confirm the
viewer stays on file #2 after the operation completes.
19. **Pan / ruler mutual exclusion:** Open a PDF. Enable pan. Enable the
ruler — confirm pan turns off. Re-enable pan — confirm the ruler turns
off.

---

### File manager

20. **Bulk delete:** Open the file manager, select 3+ local files, click
Delete Selected. Confirm all disappear in one go with no flicker, and
the sidebar updates.
21. **Dirty file not removed on deselect:** Open a PDF, make a change.
Open the file manager, deselect that file, click Open. Confirm the file
is still on the workbench.
22. **Close all from modal:** Have files open. Open the file manager,
deselect everything — confirm the button changes to "Close all files".
Click it — confirm all files close.

---

### File picker (inside tools)

23. **Search in picker:** Open a tool with a file picker (e.g. Merge,
Compare). Open the picker dropdown. Type a partial name — confirm the
list filters. Hover a row — confirm a thumbnail appears.

---

### Other flows

24. **Auto-navigate after upload:** Upload a single PDF via the modal —
confirm you land in the viewer. Upload multiple at once — confirm you
land in Active Files.
25. **Landscape PDF aspect ratio:** Upload a landscape PDF. In Active
Files view, confirm its thumbnail card is wider than tall.
26. **Compare tool clear label:** Open the Compare tool. Click to clear
the selection. Confirm the confirm button reads "Clear Selected".
27. **Settings — language and theme:** Open Settings → General. Confirm
Language and Theme controls exist. Toggle theme — confirm it switches.
28. **Tool icons:** Open the tool picker and confirm all tool icons use
the outline/unfilled style.


<img width="2056" height="1077" alt="Screenshot 2026-04-14 at 5 08
36 PM"
src="https://github.com/user-attachments/assets/b68243cb-70f6-4aac-b4e8-76ca6c617be2"
/>

<img width="2056" height="1080" alt="Screenshot 2026-04-14 at 5 08
51 PM"
src="https://github.com/user-attachments/assets/b3c1525b-6f30-4abc-b85a-ce2368e376c7"
/>

<img width="2056" height="1081" alt="Screenshot 2026-04-14 at 5 09
10 PM"
src="https://github.com/user-attachments/assets/894bb5a4-dd4b-49ae-a7da-2ee343c6c656"
/>

<img width="2056" height="1080" alt="Screenshot 2026-04-14 at 5 09
31 PM"
src="https://github.com/user-attachments/assets/9d4dd1e4-026d-4736-891d-0ae6647318d4"
/>
2026-04-21 15:48:02 +01:00
EthanHealy01 be85773852 remove selected and de-selected logic and change the compare tool to accomadate (#6095)
# Changes
         
1. Files are no longer selected/deselected, they're just on the
workbench or not.
2. Compare tool logic was changed to accommodate the changes in file
selection
3. Added a re-usable file selector component that allows you to select
files from the workbench / saved files / upload files and used it in the
compare tool.
<img width="2056" height="1081" alt="Screenshot 2026-04-10 at 5 28
07 PM"
src="https://github.com/user-attachments/assets/e04f1c0e-91fc-4564-a6f1-b9b992ca4f70"
/>

3. Files on active files are cleaner looking, on hover shows buttons,
tool runs, versions
        
<img width="325" height="378" alt="Screenshot 2026-04-10 at 4 56 52 PM"
src="https://github.com/user-attachments/assets/d29d88fe-2c72-4c5f-9650-1376085bcff6"
/>
<img width="286" height="363" alt="Screenshot 2026-04-10 at 4 57 05 PM"
src="https://github.com/user-attachments/assets/387c1709-de22-4cc8-b658-9f4c9f192af1"
/>


# How to test
                                                            
## Compare tool
  1. Open the Compare tool with no files on the workbench
3. Click the Original PDF picker → confirm the popover opens with Saved
Files and Workbench tabs
5. Switch to Saved Files, pick a file → confirm it appears as a card but
does not appear in the workbench file list
6. Pick a second file for Edited PDF → confirm the Compare button
becomes active
7. Hit Compare → confirm results appear in the workbench view
8. Hit Swap → confirm a re-run is triggered and results update correctly
9. Use the upload button → confirm a thumbnail appears on the card
immediately
10. Clear selected → confirm both slots reset and the workbench view
closes
## FileSelectorPicker                                        
1. Open either slot picker and switch between Saved Files / Workbench
tabs — confirm the active tab is remembered when you open the other
slot's picker
  3. Sort by A–Z then reopen — confirm sort preference persists
4. Upload a PDF via the Upload tab button — confirm thumbnail shows in
the card, file appears in Saved Files on next open
5. With a file in the other slot, confirm it is hidden from both tab
lists (excludeIds)
## File editor
1. Load 3+ files, run any tool (e.g. Compress) — confirm it runs on all
files without needing to select any
  2. Confirm Close All removes all files from the workbench
2026-04-14 17:03:59 +01:00
126 changed files with 6491 additions and 2671 deletions
+2
View File
@@ -2,6 +2,8 @@ dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier)
src-tauri/target/
node_modules/
src-tauri/target/
src-tauri/gen/
public/vendor/
public/pdfjs*/
public/js/thirdParty/
+102 -36
View File
@@ -52,8 +52,8 @@ exportAndContinue = "Export & Continue"
false = "False"
fileNotSavedToDisk = "Not saved to disk"
fileSavedToDisk = "File saved to disk"
fileSelected = "Selected: {{filename}}"
filesSelected = "{{count}} files selected"
fileSelected = "{{filename}}"
filesSelected = "{{count}} files"
font = "Font"
fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text."
fontTypeTooltip = "Font family for the page numbers. Choose based on your document style."
@@ -82,7 +82,7 @@ multiPdfPrompt = "Select PDFs (2+)"
never = "Never"
no = "No"
noFavourites = "No favourites added"
noFileSelected = "No file selected. Please upload one."
noFileSelected = "No file loaded. Please upload one."
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
noOperationToUndo = "No operation to undo"
notAuthenticatedMessage = "User not authenticated."
@@ -2716,6 +2716,7 @@ used = "used"
[compare]
addFilesHint = "Add PDFs in the Files step to enable selection."
clearSelected = "Clear selected"
clearSlot = "Remove file"
cta = "Compare"
header = "Compare PDFs"
loading = "Comparing..."
@@ -2741,7 +2742,7 @@ label = "Original document"
placeholder = "Select the original PDF"
[compare.clear]
confirm = "Clear and return"
confirm = "Clear Selected"
confirmBody = "This will close the current comparison and take you back to Active Files."
confirmTitle = "Clear selected PDFs?"
@@ -2767,6 +2768,7 @@ title = "These PDFs look highly different"
[compare.edited]
label = "Edited PDF"
selectBaseFirst = "Select original PDF first"
placeholder = "Select the edited PDF"
[compare.error]
@@ -2848,6 +2850,7 @@ pageLabel = "Page"
[compare.swap]
confirm = "Swap and Re-run"
label = "Swap"
confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?"
confirmTitle = "Re-run comparison?"
@@ -3097,7 +3100,7 @@ maintainAspectRatio = "Maintain Aspect Ratio"
markdown = "Markdown"
maxAttachmentSize = "Maximum attachment size (MB)"
multiple = "Multiple"
noFileSelected = "No file selected. Use the file panel to add files."
noFileSelected = "No file loaded. Use the file panel to add files."
odpExt = "OpenDocument Presentation (.odp)"
odtExt = "OpenDocument Text (.odt)"
officeDocs = "Office Documents (Word, Excel, PowerPoint)"
@@ -3118,9 +3121,9 @@ pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will b
pptExt = "PowerPoint (.pptx)"
results = "Results"
rtfExt = "Rich Text Format (.rtf)"
selectedFiles = "Selected files"
selectFilesPlaceholder = "Select files in the main view to get started"
selectSourceFormatFirst = "Select a source format first"
selectedFiles = "Active files"
selectFilesPlaceholder = "Add files to the workbench to get started"
selectSourceFormatFirst = "Choose a source format first"
settings = "Settings"
single = "Single"
sourceFormatPlaceholder = "Source format"
@@ -3275,7 +3278,7 @@ forRegularWork = "For regular PDF work:"
[crop]
autoCrop = "Auto-crop whitespace"
header = "Crop PDF"
noFileSelected = "Select a PDF file to begin cropping"
noFileSelected = "Add a PDF file to begin cropping"
reset = "Reset to full PDF"
submit = "Apply Crop"
title = "Crop"
@@ -3480,6 +3483,8 @@ unlockAll = "Use for all ({{count}})"
unlockAllPartialFail = "Wrong password for: {{names}}"
unlockAllSuccess = "Unlocked {{count}} file(s)."
unlockPrompt = "Unlock PDF to continue"
viewerLocked = "This PDF is password-protected"
viewerUnlock = "Unlock"
[encryptedPdfUnlock.password]
label = "PDF password"
@@ -3587,25 +3592,29 @@ or = "or"
[fileEditor]
addFiles = "Add Files"
pageCount = "{{count}} pages"
pageCount_one = "{{count}} page"
pageCount_other = "{{count}} pages"
[fileManager]
active = "Active"
addToUpload = "Add to Upload"
changesNotUploaded = "Changes not uploaded"
clearAll = "Clear All"
clearSelection = "Clear Selection"
clearSelection = "Clear"
clickToUpload = "Click to upload files"
closeAllFiles = "Close all files"
closeFile = "Close File"
cloudFile = "Cloud file"
copyCreated = "Copy saved to this device."
copyFailed = "Could not create a copy."
delete = "Delete"
deleteAll = "Delete All"
deleteSelected = "Delete Selected"
deselectAll = "Deselect All"
deleteSelected = "Delete Files"
deselectAll = "Uncheck All"
details = "File Details"
download = "Download"
downloadSelected = "Download Selected"
downloadSelected = "Download Files"
dragDrop = "Drag & Drop files here"
dropFilesHere = "Drop files here"
failedToLoad = "Failed to load file to active set."
@@ -3614,7 +3623,7 @@ fileFormat = "Format"
fileHistory = "File History"
fileName = "Name"
fileSize = "Size"
filesSelected = "files selected"
filesSelected = "files"
filesStored = "files stored"
fileVersion = "Version"
filterAll = "All"
@@ -3640,7 +3649,7 @@ mobileUpload = "Mobile Upload"
mobileUploadNotAvailable = "Mobile upload not enabled"
myFiles = "My Files"
noFiles = "No files available"
noFileSelected = "No files selected"
noFileSelected = "No files chosen"
noFilesFound = "No files found matching your search"
noRecentFiles = "No recent files found"
openFile = "Open File"
@@ -3663,18 +3672,19 @@ removeSharedPrompt = "This file is shared with you. You can remove it from this
removeSharedServerOnlyBlockedPrompt = "This file is shared with you and stored only on the server."
removeSharedServerOnlyPrompt = "This file is shared with you and stored only on the server. Remove it from your list?"
restore = "Restore"
saveSelected = "Save Selected"
saveSelected = "Save Files"
searchFiles = "Search files..."
selectAll = "Select All"
selectedCount = "{{count}} selected"
selectedFiles = "Selected Files"
selectAll = "Check All"
selectedCount = "{{count}} checked"
selectedFiles = "Files Added"
share = "Share"
shared = "Shared"
sharedByYou = "Shared by you"
sharedEditNoticeBody = "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy."
sharedEditNoticeConfirm = "Got it"
sharedEditNoticeTitle = "Read-only server copy"
sharedWithYou = "Shared with you"
shareSelected = "Share Selected"
shareSelected = "Share Files"
sharing = "Sharing"
showAll = "Show All"
showHistory = "Show History"
@@ -3691,24 +3701,59 @@ supportMessage = "Powered by browser database storage for unlimited capacity"
synced = "Synced"
title = "Upload PDF Files"
toolChain = "Tools Applied"
totalSelected = "Total Selected"
totalSelected = "Total Files"
unsupported = "Unsupported"
unzip = "Unzip"
updateOnServer = "Update on Server"
uploadError = "Failed to upload some files."
uploadSelected = "Upload Selected"
uploadSelected = "Upload Files"
uploadToServer = "Upload to Server"
[files]
addFiles = "Add files"
created = "Created"
selectFromWorkbench = "Select files from the workbench or "
selectMultipleFromWorkbench = "Select at least {{count}} files from the workbench or "
selectFromWorkbench = "Add files to the workbench or "
selectMultipleFromWorkbench = "Add at least {{count}} files to the workbench or "
size = "File Size"
title = "Files"
upload = "Upload"
uploadFiles = "Upload Files"
[fileSelectorPicker]
empty = "No files available"
placeholder = "Select file"
search = "Filter files…"
tabListLabel = "Saved files, workbench, upload"
upload = "Upload from computer"
[fileSelectorPicker.sort]
dateAsc = "Oldest first"
dateDesc = "Newest first"
dateLabel = "Latest"
nameAsc = "A to Z"
nameDesc = "Z to A"
nameLabel = "AZ"
[fileSelectorPicker.tabs]
saved = "Saved files"
upload = "Upload"
workbench = "Workbench"
[fileSidebar]
addFiles = "Add files"
collapse = "Collapse sidebar"
dropHint = "Open files to get started"
expand = "Expand sidebar"
files = "Files"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
noFiles = "No files yet"
openFileManager = "Open file manager"
openFromComputer = "Open from computer"
openSettings = "Open settings"
search = "Search"
searchPlaceholder = "Search files..."
[fileToPDF]
credit = "This service uses LibreOffice and Unoconv for file conversion."
header = "Convert any file to PDF"
@@ -4580,7 +4625,7 @@ goToFileEditor = "Go to file editor"
submit = "Merge"
tags = "merge,Page operations,Back end,server side"
title = "Merge"
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them."
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to add them."
[merge.error]
failed = "An error occurred while merging the PDFs."
@@ -4603,7 +4648,7 @@ title = "Remove Digital Signature"
ascending = "Ascending"
dateModified = "Date Modified"
descending = "Descending"
description = "Files will be merged in the order they're selected. Drag to reorder or sort below."
description = "Files will be merged in the order shown. Drag to reorder or sort below."
filename = "File Name"
label = "Sort By"
sort = "Sort"
@@ -4984,8 +5029,8 @@ text = "Foreground places the overlay on top of the page. Background places it b
title = "Overlay Position"
[pageEdit]
deselectAll = "Select None"
selectAll = "Select All"
deselectAll = "None"
selectAll = "Check All"
[pageEditor]
actualSize = "Actual Size"
@@ -6498,6 +6543,7 @@ title = "Invert All Colours"
[rightRail]
annotations = "Annotations"
applyRedactionsFirst = "Apply redactions first"
closeAll = "Close All Files"
closePdf = "Close PDF"
closeSelected = "Close Selected Files"
deleteSelected = "Delete Selected Pages"
@@ -6527,6 +6573,7 @@ search = "Search PDF"
selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
selectLanguage = "Select language"
share = "Share"
toggleAnnotations = "Toggle Annotations Visibility"
toggleAttachments = "Toggle Attachments"
toggleBookmarks = "Toggle Bookmarks"
@@ -6767,7 +6814,7 @@ defaultPdfEditorChecking = "Checking..."
defaultPdfEditorInactive = "Another application is set as default"
defaultPdfEditorSet = "Already Default"
defaultStartupView = "Default view on launch"
defaultStartupViewDescription = "Choose which tab is active in the left column when the app starts"
defaultStartupViewDescription = "Choose which view is active when the app starts"
defaultToolPickerMode = "Default tool picker mode"
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
defaultViewerZoom = "Default reader zoom"
@@ -6777,8 +6824,14 @@ hideUnavailableConversions = "Hide unavailable conversions"
hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
hideUnavailableTools = "Hide unavailable tools"
hideUnavailableToolsDescription = "Remove tools that have been disabled by your server instead of showing them greyed out."
language = "Language"
languageDescription = "Choose the display language"
logout = "Log out"
setAsDefault = "Set as Default"
theme = "Theme"
themeDark = "Dark"
themeDescription = "Switch between light and dark mode"
themeLight = "Light"
title = "General"
user = "User"
@@ -7550,8 +7603,8 @@ accessLimitedCommenter = "Comment access is coming soon. Ask the owner for edito
accessLimitedTitle = "Limited access"
accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download."
addUser = "Add"
bulkDescription = "Create one link to share all selected files with signed-in users."
bulkTitle = "Share selected files"
bulkDescription = "Create one link to share all checked files with signed-in users."
bulkTitle = "Share checked files"
commenterHint = "Commenting is coming soon."
copied = "Link copied to clipboard"
copy = "Copy"
@@ -7570,7 +7623,7 @@ errorTitle = "Share failed"
expiredBody = "This share link is invalid or has expired."
expiredTitle = "Link expired"
failure = "Unable to generate a share link. Please try again."
fileCount = "{{count}} files selected"
fileCount = "{{count}} files"
fileLabel = "File"
generate = "Generate Link"
generated = "Share link generated"
@@ -7626,12 +7679,12 @@ viewed = "Viewed"
viewsCount = "Views: {{count}}"
[storageUpload]
bulkDescription = "This uploads the selected files to your server storage."
bulkTitle = "Upload selected files"
bulkDescription = "This uploads the checked files to your server storage."
bulkTitle = "Upload checked files"
description = "This uploads the current file to server storage for your own access."
errorTitle = "Upload failed"
failure = "Upload failed. Please check your login and storage settings."
fileCount = "{{count}} files selected"
fileCount = "{{count}} files"
fileLabel = "File"
hint = "Public links and access modes are controlled by your server settings."
more = " +{{count}} more"
@@ -7791,19 +7844,26 @@ settings = "Settings"
[tool]
endpointUnavailable = "This tool is unavailable on your server."
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
filesLoading = "Files are still loading, please wait."
filesLoadingProgress = "{{loaded}} / {{total}} files loading..."
invalidParams = "Fill in the required settings."
noFiles = "Add a file to get started."
scopeFiles = "files"
scopeThisFile = "this file"
selectFilesHint = "Select files in Active Files to run this tool"
singleFileScope = "Only applying to: {{fileName}}"
viewerMode = "Switch to the file editor to select multiple files."
viewerMode = "Switch to the file editor to add multiple files."
[toolPanel]
allTools = "All tools"
alpha = "Alpha"
backToTools = "Back to all tools"
collapse = "Collapse panel"
comingSoon = "Coming soon:"
expand = "Expand panel"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
search = "Search tools"
[toolPanel.fullscreen]
comingSoon = "Coming soon:"
@@ -8354,6 +8414,12 @@ bullet3 = "Image will be resized to fit signature area"
description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo."
title = "Upload Signature Image"
[workbenchBar]
activeFiles = "Active Files"
multiTool = "Multi-Tool"
viewer = "Viewer"
[workspace]
title = "Workspace"
+21 -2
View File
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Modal } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { useFileManager } from "@app/hooks/useFileManager";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -17,6 +18,7 @@ import {
} from "@app/services/googleDrivePickerService";
import { loadScript } from "@app/utils/scriptLoader";
import { useAllFiles } from "@app/contexts/FileContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -28,6 +30,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
closeFilesModal,
onFileUpload,
onRecentFileSelect,
maxSelectable,
} = useFilesModalContext();
const { config } = useAppConfig();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
@@ -35,6 +38,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { actions: fileActions } = useFileActions();
// Get active file IDs from FileContext to show which files are already loaded
const { fileIds: activeFileIds } = useAllFiles();
@@ -83,11 +87,24 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const handleRemoveFileByIndex = useCallback(
async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
await handleRemoveFile(
index,
recentFiles,
setRecentFiles,
(fileId) => {
fileActions.removeFiles([fileId], false);
},
refreshRecentFiles,
);
},
[handleRemoveFile, recentFiles],
[handleRemoveFile, recentFiles, fileActions, refreshRecentFiles],
);
const handleBulkRemove = useCallback((fileIds: FileId[]) => {
const idSet = new Set(fileIds as string[]);
setRecentFiles((prev) => prev.filter((f) => !idSet.has(f.id as string)));
}, []);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
checkMobile();
@@ -211,7 +228,9 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}
maxSelectable={maxSelectable}
onFileRemove={handleRemoveFileByIndex}
onBulkRemove={handleBulkRemove}
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
@@ -1,7 +1,6 @@
import React, { useRef, useState } from "react";
import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
@@ -69,7 +68,7 @@ const AddFileCard = ({
/>
<div
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
className={`${styles.addFileCard} select-none flex flex-col transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t("fileEditor.addFiles", "Add files")}
@@ -81,17 +80,6 @@ const AddFileCard = ({
}
}}
>
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
</div>
<div className={styles.headerIndex}>
{t("fileEditor.addFiles", "Add Files")}
</div>
<div className={styles.kebab} />
</div>
{/* Main content area */}
<div className={styles.addFileContent}>
{/* Stirling PDF Branding */}
@@ -328,42 +328,33 @@
========================= */
.addFileCard {
width: 260px;
max-width: 260px;
height: calc(310px - 0.5rem);
margin: 0.5rem auto 0;
background: var(--file-card-bg);
border: 2px dashed var(--border-default);
border-radius: 0.0625rem;
border: 1.5px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-md);
cursor: pointer;
transition: all 0.18s ease;
max-width: 100%;
max-height: 100%;
transition:
box-shadow 0.18s ease,
transform 0.2s ease,
border-color 0.18s ease;
overflow: hidden;
margin-left: 0.5rem;
margin-right: 0.5rem;
opacity: 0.7;
}
.addFileCard:hover {
opacity: 1;
border-color: var(--color-blue-500);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
border-color: var(--accent-interactive);
}
.addFileCard:focus {
outline: 2px solid var(--color-blue-500);
.addFileCard:focus-visible {
outline: 2px solid var(--accent-interactive);
outline-offset: 2px;
}
.addFileHeader {
background: var(--bg-subtle);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
.addFileCard:hover .addFileHeader {
background: var(--color-blue-500);
color: white;
}
.addFileContent {
flex: 1;
display: flex;
@@ -1,13 +1,12 @@
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import { flushSync } from "react-dom";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { Center, Box, LoadingOverlay } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
useFileSelection,
useFileState,
useFileManagement,
useFileActions,
useFileContext,
} from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
@@ -19,7 +18,6 @@ import FilePickerModal from "@app/components/shared/FilePickerModal";
import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFile } from "@app/services/downloadService";
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface FileEditorProps {
@@ -46,17 +44,13 @@ const FileEditor = ({
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions: fileActions } = useFileActions();
const { actions: fileContextActions } = useFileContext();
const { clearAllFileErrors } = fileContextActions;
const { selectedFileIds, setSelectedFiles } = useFileSelection();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(
() => selectors.getStirlingFileStubs(),
[state.files.byId, state.files.ids],
);
const selectedFileIds = state.ui.selectedFileIds;
const totalItems = state.files.ids.length;
const selectedCount = selectedFileIds.length;
// Get navigation actions
const { actions: navActions } = useNavigationActions();
@@ -64,9 +58,6 @@ const FileEditor = ({
// Get viewer context for setting active file index and ID
const { setActiveFileIndex, setActiveFileId } = useViewer();
// Get file selection context
const { setSelectedFiles } = useFileSelection();
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
@@ -93,7 +84,6 @@ const FileEditor = ({
expandable: true,
});
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Current tool (for enforcing maxFiles limits)
const { selectedTool } = useToolWorkflow();
@@ -104,65 +94,7 @@ const FileEditor = ({
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
// Enable selection mode automatically in tool mode
useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
}, [toolMode]);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds)
? selectedFileIds
: [];
// Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds;
const handleSelectAllFiles = useCallback(() => {
// Respect maxAllowed: if limited, select the last N files
const allIds = state.files.ids;
const idsToSelect = Number.isFinite(maxAllowed)
? allIds.slice(-maxAllowed)
: allIds;
setSelectedFiles(idsToSelect);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on select all:", error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
const handleDeselectAllFiles = useCallback(() => {
setSelectedFiles([]);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on deselect:", error);
}
}
}, [setSelectedFiles, clearAllFileErrors]);
const handleCloseSelectedFiles = useCallback(() => {
if (selectedFileIds.length === 0) return;
void removeFiles(selectedFileIds, false);
setSelectedFiles([]);
}, [selectedFileIds, removeFiles, setSelectedFiles]);
useFileEditorRightRailButtons({
totalItems,
selectedCount,
onSelectAll: handleSelectAllFiles,
onDeselectAll: handleDeselectAllFiles,
onCloseSelected: handleCloseSelectedFiles,
});
// Process uploaded files using context
// ZIP extraction is now handled automatically in FileContext based on user preferences
@@ -199,56 +131,6 @@ const FileEditor = ({
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
);
const toggleFile = useCallback(
(fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed =
!toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (
Number.isFinite(maxAllowed) &&
currentSelectedIds.length >= maxAllowed
) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
},
[
setSelectedFiles,
toolMode,
_setStatus,
activeStirlingFileStubs,
selectedTool?.maxFiles,
],
);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
@@ -495,16 +377,7 @@ const FileEditor = ({
<Box p="md">
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
📁
</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">
Upload PDF files, ZIP archives, or load from storage to get
started
</Text>
</Stack>
<AddFileCard onFileSelect={handleFileUpload} />
</Center>
) : (
<div
@@ -531,12 +404,8 @@ const FileEditor = ({
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
@@ -0,0 +1,53 @@
import React from "react";
import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 1024,
lastModified: 0,
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
...overrides,
});
const renderName = (file: StirlingFileStub) =>
render(
<MantineProvider>
<FileEditorFileName file={file} />
</MantineProvider>,
);
describe("FileEditorFileName (core / web)", () => {
test.each([
{ name: "not-saved", file: buildFileStub() },
{
name: "dirty",
file: buildFileStub({ localFilePath: "/tmp/report.pdf", isDirty: true }),
},
{
name: "saved",
file: buildFileStub({ localFilePath: "/tmp/report.pdf", isDirty: false }),
},
])("does not render a save indicator ($name)", ({ file }) => {
renderName(file);
expect(screen.queryByLabelText("fileNotSavedToDisk")).toBeNull();
expect(screen.queryByLabelText("unsavedChanges")).toBeNull();
expect(screen.queryByLabelText("fileSavedToDisk")).toBeNull();
});
test("renders the filename", () => {
renderName(buildFileStub({ name: "invoice.pdf" }));
expect(screen.getByText("invoice.pdf")).toBeInTheDocument();
});
});
@@ -1,13 +1,18 @@
import React from "react";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { truncateCenter } from "@app/utils/textUtils";
interface FileEditorFileNameProps {
file: StirlingFileStub;
maxLength?: number;
}
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
<PrivateContent>{file.name}</PrivateContent>
const FileEditorFileName = ({
file,
maxLength = 40,
}: FileEditorFileNameProps) => (
<PrivateContent>{truncateCenter(file.name, maxLength)}</PrivateContent>
);
export default FileEditorFileName;
@@ -0,0 +1,246 @@
.card {
width: 260px;
max-width: 260px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 10px 10px 12px;
position: relative;
--file-text-height: 58px;
--file-text-bottom: 12px;
--thumb-text-gap: 6px;
padding-bottom: calc(
var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap)
);
user-select: none;
cursor: pointer;
}
/* Inner wrapper — centers [toolChain + thumbnail + hover menu] as a unit */
.thumbInner {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100%;
max-width: 100%;
}
/* Groups toolchain bar + thumbnail so they center together inside thumbWrap */
.thumbUnit {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-height: 100%;
}
/* ToolChain bar sits directly above the thumbnail card — always rendered for consistent sizing */
.toolChainBar {
flex-shrink: 0;
width: 100%;
height: 22px;
text-align: center;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
line-height: 22px;
margin-bottom: 4px;
}
.card:hover .toolChainBar,
.card:focus-within .toolChainBar {
opacity: 1;
}
.thumbWrap {
width: 100%;
position: relative;
height: 310px;
display: flex;
align-items: center;
justify-content: center;
}
.thumbContainer {
aspect-ratio: var(--thumb-aspect, 8.5 / 11);
width: 100%;
max-width: 100%;
max-height: 100%;
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
position: relative;
background: var(--bg-surface);
transition:
box-shadow 0.15s ease,
transform 0.2s ease;
}
.thumbContainer:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.thumbImage {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
background: var(--bg-surface);
display: block;
position: relative;
z-index: 1;
}
/* Text block pinned below thumbnail */
.fileText {
position: absolute;
left: 10px;
right: 10px;
bottom: var(--file-text-bottom);
height: var(--file-text-height);
text-align: center;
pointer-events: none;
--file-name-line-height: 18px;
--file-name-lines: 2;
--file-meta-line-height: 14px;
--file-meta-gap: 4px;
}
.fileName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: var(--file-name-line-height);
height: calc(var(--file-name-lines) * var(--file-name-line-height));
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: break-word;
word-break: normal;
}
.fileMeta {
font-size: 11px;
color: var(--text-secondary);
line-height: var(--file-meta-line-height);
height: var(--file-meta-line-height);
overflow: hidden;
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
/* CSS-hover driven menu visibility */
.card [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 0;
pointer-events: none;
}
.card:hover
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"],
.card:focus-within
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 1;
pointer-events: auto;
}
/* Error overlay */
.errorOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(220, 38, 38, 0.12);
border-radius: 12px;
z-index: 2;
pointer-events: none;
}
.errorPill {
background: var(--mantine-color-red-6);
color: white;
border-radius: 12px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
}
/* Badges row pinned to top-left of thumbnail - always visible */
.thumbBadges {
position: absolute;
top: 6px;
left: 6px;
display: flex;
gap: 4px;
align-items: center;
z-index: 3;
pointer-events: none;
}
.ownershipBadge {
background: var(--mantine-color-blue-6);
color: white;
border-radius: 6px;
padding: 2px 7px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.versionBadgeThumb {
background: var(--mantine-color-blue-6);
border-radius: 4px;
padding: 1px 5px;
font-size: 10px;
font-weight: 700;
color: white;
line-height: 16px;
white-space: nowrap;
}
.pinnedBadge {
background: var(--mantine-color-yellow-6);
color: white;
border-radius: 4px;
padding: 2px 4px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.dragHandle {
position: absolute;
bottom: 6px;
right: 6px;
cursor: grab;
color: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.card:hover .dragHandle,
.card:focus-within .dragHandle {
opacity: 0.5;
pointer-events: auto;
}
@@ -1,14 +1,12 @@
import React, { useState, useCallback, useRef, useMemo } from "react";
import {
Text,
ActionIcon,
CheckboxIndicator,
Tooltip,
Modal,
Button,
Group,
Stack,
Loader,
ActionIcon,
Tooltip,
} from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast";
@@ -21,7 +19,6 @@ import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
@@ -31,11 +28,10 @@ import {
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/file/fileHooks";
import { FileId } from "@app/types/file";
import { formatFileSize } from "@app/utils/fileUtils";
import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, {
HoverAction,
@@ -46,17 +42,15 @@ import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { truncateCenter } from "@app/utils/textUtils";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
interface FileEditorThumbnailProps {
file: StirlingFileStub;
index: number;
totalFiles: number;
selectedFiles: FileId[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (
sourceFileId: FileId,
targetFileId: FileId,
@@ -70,12 +64,8 @@ interface FileEditorThumbnailProps {
const FileEditorThumbnail = ({
file,
index,
selectedFiles,
onToggleFile,
onCloseFile,
onViewFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
@@ -95,41 +85,41 @@ const FileEditorThumbnail = ({
openEncryptedUnlockPrompt,
} = useFileContext();
const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useIsMobile();
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find((f) => f.fileId === file.id);
}, [activeFiles, file.id]);
const actualFile = useMemo(
() => activeFiles.find((f) => f.fileId === file.id),
[activeFiles, file.id],
);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const hasError = state.ui.errorFileIds.includes(file.id);
const pageCount = file.processedFile?.totalPages || 0;
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
const {
isEncrypted,
thumbnail: displayThumbnail,
isGenerating: isThumbGenerating,
} = useFileThumbnail(file);
// Aspect ratio from page dimensions, falling back to letter size
const firstPage = file.processedFile?.pages?.[0];
const firstPageRotation = firstPage?.rotation ?? 0;
const isLandscape = firstPageRotation === 90 || firstPageRotation === 270;
const thumbAspect = (() => {
const w = firstPage?.width;
const h = firstPage?.height;
if (w && h && w > 0 && h > 0) {
// width/height are effective (post-rotation) dims from PDFium, so use
// them directly — no swapping needed.
return `${w} / ${h}`;
}
return isLandscape ? "11 / 8.5" : "8.5 / 11";
})();
const handleRef = useRef<HTMLSpanElement | null>(null);
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Meta formatting ----
const prettySize = useMemo(() => {
return formatFileSize(file.size);
}, [file.size]);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
@@ -143,6 +133,7 @@ const FileEditorThumbnail = ({
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled =
uploadEnabled && config?.storageSharingEnabled === true;
@@ -178,7 +169,13 @@ const FileEditorThumbnail = ({
}).format(d);
}, [file.lastModified]);
// ---- Drag & drop wiring ----
const [isDragging, setIsDragging] = useState(false);
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
@@ -191,14 +188,10 @@ const FileEditorThumbnail = ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id], // Always drag only this file, ignore selection state
selectedFiles: [file.id],
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
});
const dropCleanup = dropTargetForElements({
@@ -211,10 +204,7 @@ const FileEditorThumbnail = ({
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
onDragEnter: () => setIsDragOver(true),
onDragLeave: () => setIsDragOver(false),
onDrop: ({ source }) => {
setIsDragOver(false);
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
@@ -229,13 +219,14 @@ const FileEditorThumbnail = ({
dropCleanup();
};
},
[file.id, file.name, selectedFiles, onReorderFiles],
[file.id, file.name, onReorderFiles],
);
// Handle close with confirmation
const handleCloseWithConfirmation = useCallback(() => {
setShowCloseModal(true);
}, []);
const handleCloseWithConfirmation = useCallback(
() => setShowCloseModal(true),
[],
);
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
const handleConfirmClose = useCallback(() => {
onCloseFile(file.id);
@@ -278,7 +269,6 @@ const FileEditorThumbnail = ({
return;
}
}
// Then close
onCloseFile(file.id);
alert({
alertType: "success",
@@ -296,11 +286,6 @@ const FileEditorThumbnail = ({
fileActions,
]);
const handleCancelClose = useCallback(() => {
setShowCloseModal(false);
}, []);
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(
() => [
{
@@ -312,6 +297,35 @@ const FileEditorThumbnail = ({
onViewFile(file.id);
},
},
{
id: "pin",
icon: <PushPinIcon style={{ fontSize: 20 }} />,
label: isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)"),
onClick: (e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({
alertType: "neutral",
title: `Unpinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
} else {
pinFile(actualFile);
alert({
alertType: "success",
title: `Pinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
}
}
},
},
{
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
@@ -321,36 +335,32 @@ const FileEditorThumbnail = ({
onDownloadFile(file.id);
},
},
...(canUpload || canShare
...(canUpload
? [
...(canUpload
? [
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
{
@@ -389,7 +399,10 @@ const FileEditorThumbnail = ({
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
@@ -397,13 +410,13 @@ const FileEditorThumbnail = ({
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
],
);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try {
fileActions.clearFileError(file.id);
@@ -415,7 +428,6 @@ const FileEditorThumbnail = ({
sharedEditNoticeShownRef.current = true;
setShowSharedEditNotice(true);
}
onToggleFile(file.id);
};
const handleCardDoubleClick = () => {
@@ -423,12 +435,9 @@ const FileEditorThumbnail = ({
onViewFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
if (!isSupported) return styles.headerUnsupported;
return isSelected ? styles.headerSelected : styles.headerResting;
};
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
.filter(Boolean)
.join(" - ");
return (
<div
@@ -436,257 +445,128 @@ const FileEditorThumbnail = ({
data-file-id={file.id}
data-testid="file-thumbnail"
data-tour="file-card-checkbox"
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={
{
opacity: isDragging ? 0.4 : 1,
outline: isDragOver
? "3px dashed var(--mantine-color-blue-5, #3b82f6)"
: undefined,
outlineOffset: isDragOver ? "2px" : undefined,
transform: isDragOver ? "scale(1.02)" : undefined,
transition:
"outline 120ms ease, transform 120ms ease, opacity 120ms ease",
// Tag each card with a stable, unique view-transition-name so the
// browser can animate the reorder (see FileEditor.handleReorderFiles,
// which dispatches reorderFiles inside document.startViewTransition).
viewTransitionName: `file-card-${file.id}`,
} as React.CSSProperties
}
className={`${styles.card} select-none`}
style={{ opacity: isDragging ? 0.9 : 1 }}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
onMouseEnter={() => setShowHoverMenu(true)}
onMouseLeave={() => setShowHoverMenu(false)}
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{hasError ? (
<div className={styles.errorPill}>
<span>{t("error._value", "Error")}</span>
</div>
) : isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
color="var(--checkbox-checked-bg)"
/>
) : (
<div className={styles.unsupportedPill}>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
{/* Centered index */}
<div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
>
{index + 1}
</div>
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
<div className={styles.thumbInner}>
{/* Thumbnail area */}
<div className={styles.thumbWrap}>
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
<div className={styles.thumbUnit}>
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
<div className={styles.toolChainBar}>
{file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth="100%"
color="var(--mantine-color-gray-7)"
/>
)}
</div>
<div
className={styles.thumbContainer}
data-supported={isSupported}
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
>
<ActionIcon
aria-label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
>
<LockOpenIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip
label={
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
>
<ActionIcon
aria-label={
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
data-tour="file-card-pin"
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({
alertType: "neutral",
title: `Unpinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
} else {
pinFile(actualFile);
alert({
alertType: "success",
title: `Pinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
}
{/* Error overlay */}
{hasError && (
<div className={styles.errorOverlay}>
<span className={styles.errorPill}>
{t("error._value", "Error")}
</span>
</div>
)}
{/* Thumbnail image or loading state */}
<DocumentThumbnail
file={file}
thumbnail={displayThumbnail || undefined}
isEncrypted={isEncrypted}
isLoading={
!isEncrypted &&
!displayThumbnail &&
(isThumbGenerating ||
file.type?.startsWith("application/pdf") ||
file.type?.startsWith("image/"))
}
}}
>
{isPinned ? (
<PushPinIcon fontSize="small" />
) : (
<PushPinOutlinedIcon fontSize="small" />
)}
</ActionIcon>
</Tooltip>
</div>
</div>
{/* Title + meta line */}
<div
style={{
padding: "0.5rem",
textAlign: "center",
background: "var(--file-card-bg)",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
>
<Text size="lg" fw={700} className={styles.title} title={file.name}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</Text>
<Text
size="sm"
c="dimmed"
className={styles.meta}
lineClamp={3}
title={`${extUpper || "FILE"}${prettySize}`}
>
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
{`v${file.versionNumber} - `}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ""}
{pageLabel ? ` - ${pageLabel}` : ""}
</Text>
</div>
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={
isSupported || hasError
? undefined
: { filter: "grayscale(80%)", opacity: 0.6 }
}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl ? (
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = "none";
img.parentElement?.setAttribute("data-thumb-missing", "true");
}}
style={{
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
iconSize="6rem"
imgClassName={styles.thumbImage}
onImageError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
{/* Badges — visible on hover via CSS */}
<div className={styles.thumbBadges}>
<span className={styles.versionBadgeThumb}>
v{file.versionNumber}
</span>
{isPinned && (
<span className={styles.pinnedBadge}>
<PushPinIcon style={{ fontSize: 12 }} />
</span>
)}
{isSharedFile && !isOwnedOrLocal && (
<span className={styles.ownershipBadge}>
{t("fileManager.sharedWithYou", "Shared")}
</span>
)}
{isEncrypted && (
<Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
>
<ActionIcon
size="xs"
variant="filled"
color="yellow"
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
style={{ pointerEvents: "auto" }}
>
<LockOpenIcon style={{ fontSize: 12 }} />
</ActionIcon>
</Tooltip>
)}
</div>
</div>
</div>
{/* end thumbUnit */}
{/* Drag handle */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
</div>
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
{/* Tool chain display at bottom */}
{file.toolHistory && (
<div
style={{
position: "absolute",
bottom: "4px",
left: "4px",
right: "4px",
padding: "4px 6px",
textAlign: "center",
fontWeight: 600,
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth={"100%"}
color="var(--mantine-color-gray-7)"
/>
</div>
)}
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
<HoverActionMenu
show={isMobile}
actions={hoverActions}
position="outside"
visibility="cssHover"
/>
</div>
{/* Hover Menu */}
<HoverActionMenu
show={showHoverMenu || isMobile}
actions={hoverActions}
position="outside"
/>
{/* File name + meta */}
<div className={styles.fileText}>
<p className={styles.fileName}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</p>
<p className={styles.fileMeta}>{metaLine}</p>
</div>
{/* Close Confirmation Modal */}
<Modal
@@ -703,7 +583,7 @@ const FileEditorThumbnail = ({
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
@@ -730,7 +610,7 @@ const FileEditorThumbnail = ({
)}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
@@ -748,6 +628,8 @@ const FileEditorThumbnail = ({
)}
</Stack>
</Modal>
{/* Shared edit notice modal */}
<Modal
opened={showSharedEditNotice}
onClose={() => setShowSharedEditNotice(false)}
@@ -18,6 +18,7 @@ interface CompactFileDetailsProps {
onPrevious: () => void;
onNext: () => void;
onOpenFiles: () => void;
canCloseAll?: boolean;
}
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
@@ -30,6 +31,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
onPrevious,
onNext,
onOpenFiles,
canCloseAll = false,
}) => {
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
@@ -94,7 +96,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>
{currentFile ? currentFile.name : "No file selected"}
{currentFile ? currentFile.name : "No file loaded"}
</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
@@ -149,18 +151,21 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
<Button
size="sm"
onClick={onOpenFiles}
disabled={!hasSelection}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor: hasSelection
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
: selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);
@@ -12,7 +12,8 @@ interface FileDetailsProps {
}
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } =
useFileManagerContext();
const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
@@ -21,6 +22,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
const currentFile =
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
const hasSelection = selectedFiles.length > 0;
const hasActiveFiles = activeFileIds.length > 0;
// Enable "Close all files" when nothing is checked but files are open in workbench
const canCloseAll = !hasSelection && hasActiveFiles;
// Use IndexedDB hook for the current file
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
@@ -71,6 +75,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
onPrevious={handlePrevious}
onNext={handleNext}
onOpenFiles={onOpenFiles}
canCloseAll={canCloseAll}
/>
);
}
@@ -104,18 +109,21 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
<Button
size="md"
onClick={onOpenFiles}
disabled={!hasSelection}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor: hasSelection
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
: selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);
@@ -11,7 +11,6 @@ interface FileHistoryGroupProps {
onDownloadSingle: (file: StirlingFileStub) => void;
onFileDoubleClick: (file: StirlingFileStub) => void;
onHistoryFileRemove: (file: StirlingFileStub) => void;
isFileSupported: (fileName: string) => boolean;
}
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
@@ -21,7 +20,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
onDownloadSingle,
onFileDoubleClick,
onHistoryFileRemove,
isFileSupported,
}) => {
const { t } = useTranslation();
@@ -50,7 +48,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
isSelected={false} // History files are not selectable
isSupported={isFileSupported(historyFile.name)}
onSelect={() => {}} // No selection for history files
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
onDownload={() => onDownloadSingle(historyFile)}
@@ -28,7 +28,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onHistoryFileRemove,
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
isLoading,
activeFileIds,
} = useFileManagerContext();
@@ -65,7 +64,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
<FileListItem
file={file}
isSelected={selectedFilesSet.has(file.id)}
isSupported={isFileSupported(file.name)}
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
onRemove={() => onFileRemove(index)}
onDownload={() => onDownloadSingle(file)}
@@ -82,7 +80,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onDownloadSingle={onDownloadSingle}
onFileDoubleClick={onFileDoubleClick}
onHistoryFileRemove={onHistoryFileRemove}
isFileSupported={isFileSupported}
/>
</React.Fragment>
);
@@ -39,7 +39,6 @@ import { alert } from "@app/components/toast";
interface FileListItemProps {
file: StirlingFileStub;
isSelected: boolean;
isSupported: boolean;
onSelect: (shiftKey?: boolean) => void;
onRemove: () => void;
onDownload?: () => void;
@@ -53,7 +52,6 @@ interface FileListItemProps {
const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
onDownload,
@@ -187,15 +185,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box
p="sm"
style={{
cursor: isHistoryFile || isActive ? "default" : "pointer",
backgroundColor: isActive
? "var(--file-active-bg)"
: isSelected
cursor: isHistoryFile ? "default" : "pointer",
backgroundColor:
isSelected || shouldShowHovered
? "var(--mantine-color-gray-1)"
: shouldShowHovered
? "var(--mantine-color-gray-1)"
: "var(--bg-file-list)",
opacity: isSupported ? 1 : 0.5,
: "var(--bg-file-list)",
opacity: 1,
transition: "background-color 0.15s ease",
userSelect: "none",
WebkitUserSelect: "none",
@@ -206,9 +201,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
? "3px solid var(--mantine-color-blue-4)"
: "none", // Visual indicator for history
}}
onClick={
isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)
}
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -218,16 +211,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box>
{/* Checkbox for regular files only */}
<Checkbox
checked={isActive || isSelected}
checked={isSelected}
onChange={() => {}} // Handled by parent onClick
size="sm"
pl="sm"
pr="xs"
disabled={isActive}
color={isActive ? "green" : undefined}
styles={{
input: {
cursor: isActive ? "not-allowed" : "pointer",
cursor: "pointer",
},
}}
/>
@@ -10,28 +10,12 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
interface FileSourceButtonsProps {
horizontal?: boolean;
}
/**
* Google Drive icon component - displays the branded SVG icon
* Shows grayscale when disabled
*/
const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
<img
src="/images/google-drive.svg"
alt="Google Drive"
style={{
width: "20px",
height: "20px",
opacity: disabled ? 0.5 : 1,
filter: disabled ? "grayscale(100%)" : "none",
}}
/>
);
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false,
}) => {
@@ -144,7 +128,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
<Button
variant="subtle"
color="var(--mantine-color-gray-6)"
leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
leftSection={<GoogleDriveIcon colored={isGoogleDriveEnabled} />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleGoogleDriveClick}
fullWidth={!horizontal}
@@ -1,3 +1,24 @@
/* WorkbenchBar slide-in/out animation using CSS grid trick */
.workbenchBarWrapper {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 280ms ease;
}
.workbenchBarWrapper[data-hidden="true"] {
grid-template-rows: 0fr;
}
.workbenchBarWrapper[data-no-transition="true"] {
transition: none;
}
/* Direct child must have min-height: 0 so the row can collapse below content size */
.workbenchBarInner {
min-height: 0;
overflow: hidden;
}
.workbenchScrollable {
overflow-y: auto !important;
overflow-x: hidden !important;
@@ -1,21 +1,19 @@
import { Suspense, lazy, useCallback } from "react";
import { useEffect, useState, Suspense, lazy } from "react";
import { Box, Loader, Center } from "@mantine/core";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext";
import {
useNavigationState,
useNavigationActions,
useNavigationGuard,
} from "@app/contexts/NavigationContext";
import { isBaseWorkbench } from "@app/types/workbench";
import { useViewer } from "@app/contexts/ViewerContext";
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { FileId } from "@app/types/file";
import styles from "@app/components/layout/Workbench.module.css";
import TopControls from "@app/components/shared/TopControls";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
import LandingPage from "@app/components/shared/LandingPage";
import Footer from "@app/components/shared/Footer";
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
@@ -37,7 +35,6 @@ export default function Workbench() {
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { actions: fileActions } = useFileActions();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
@@ -61,34 +58,15 @@ export default function Workbench() {
const { toolRegistry } = useToolWorkflow();
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
const hasFiles = activeFiles.length > 0;
// Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer();
// Get navigation guard for unsaved changes check when switching files
const { requestNavigation } = useNavigationGuard();
// Wrap file selection to check for unsaved changes before switching
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
const handleFileSelect = useCallback(
(index: number) => {
// Don't do anything if selecting the same file
if (index === activeFileIndex) return;
// requestNavigation handles the unsaved changes check internally
requestNavigation(() => {
setActiveFileIndex(index);
});
},
[activeFileIndex, requestNavigation, setActiveFileIndex],
);
const handleFileRemove = useCallback(
async (fileId: FileId) => {
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
},
[fileActions],
);
// Enable bar transitions after first paint so the initial hidden state shows
// without animating (landing page on load shouldn't animate the bar up).
const [barTransitionEnabled, setBarTransitionEnabled] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setBarTransitionEnabled(true));
return () => cancelAnimationFrame(raf);
}, []);
const handlePreviewClose = () => {
setPreviewFile(null);
@@ -130,7 +108,9 @@ export default function Workbench() {
return (
<FileEditor
toolMode={!!selectedToolId}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
supportedExtensions={
selectedTool?.supportedFormats || VIEWER_SUPPORTED_EXTENSIONS
}
{...(!selectedToolId && {
onOpenPageEditor: () => {
setCurrentView("pageEditor");
@@ -150,8 +130,6 @@ export default function Workbench() {
setSidebarsVisible={setSidebarsVisible}
previewFile={previewFile}
onClose={handlePreviewClose}
activeFileIndex={activeFileIndex}
setActiveFileIndex={setActiveFileIndex}
/>
);
@@ -195,7 +173,7 @@ export default function Workbench() {
);
default:
return <LandingPage />;
return null;
}
};
@@ -209,27 +187,23 @@ export default function Workbench() {
: { backgroundColor: "var(--bg-background)" }
}
>
{/* Top Controls */}
{activeFiles.length > 0 &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<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={handleFileSelect}
onFileRemove={handleFileRemove}
/>
)}
{/* Workbench Bar - animates in/out based on file presence */}
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
</div>
</div>
)}
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
@@ -168,6 +168,16 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
}, [pageEditorFiles]);
const activeFilesSignature = selectors.getFilesSignature();
// Check if there are any PDF files in FileContext (bypasses fileOrder, which is
// populated asynchronously via effect). Used to avoid a one-render flash of
// "No PDF files loaded" when PageEditor first mounts after files are added.
const hasPdfFiles = useMemo(() => {
return state.files.ids.some((id) => {
const stub = selectors.getStirlingFileStub(id);
return stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
});
}, [state.files.ids, selectors]);
// UI state
const globalProcessing = state.ui.isProcessing;
@@ -450,7 +460,6 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
const selectedPageCount = selectedPageIds.length;
const activeFileIds = selectedFileIds;
usePageEditorRightRailButtons({
totalPages,
@@ -466,8 +475,6 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
onExportSelected,
onSaveChanges: applyChanges,
exportLoading,
activeFileCount: activeFileIds.length,
closePdf,
});
// Export preview function - defined after export functions to avoid circular dependency
@@ -677,23 +684,21 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
>
<LoadingOverlay visible={globalProcessing && !initialDocument} />
{!initialDocument &&
!globalProcessing &&
selectedFileIds.length === 0 && (
<Center h="100%">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
📄
</Text>
<Text c="dimmed">No PDF files loaded</Text>
<Text size="sm" c="dimmed">
Add files to start editing pages
</Text>
</Stack>
</Center>
)}
{!initialDocument && !globalProcessing && !hasPdfFiles && (
<Center h="100%">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
📄
</Text>
<Text c="dimmed">No PDF files loaded</Text>
<Text size="sm" c="dimmed">
Add files to start editing pages
</Text>
</Stack>
</Center>
)}
{!initialDocument && globalProcessing && (
{!initialDocument && (globalProcessing || hasPdfFiles) && (
<Box p={0}>
<SkeletonLoader type="controls" />
<SkeletonLoader type="pageGrid" count={8} />
@@ -27,7 +27,7 @@ export default function PageSelectByNumberButton({
return (
<Tooltip
content={label}
position="left"
position="bottom"
offset={12}
arrow
portalTarget={document.body}
@@ -21,8 +21,6 @@ interface PageEditorRightRailButtonsParams {
onExportSelected: () => void;
onSaveChanges: () => void;
exportLoading: boolean;
activeFileCount: number;
closePdf: () => void;
}
export function usePageEditorRightRailButtons(
@@ -42,8 +40,6 @@ export function usePageEditorRightRailButtons(
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,
} = params;
const { t, i18n } = useTranslation();
@@ -64,8 +60,6 @@ export function usePageEditorRightRailButtons(
"Export Selected Pages",
);
const saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
const closePdfLabel = t("rightRail.closePdf", "Close PDF");
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
return [
{
@@ -156,17 +150,6 @@ export function usePageEditorRightRailButtons(
visible: totalPages > 0,
onClick: onSaveChanges,
},
{
id: "page-close-pdf",
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: closePdfLabel,
ariaLabel: closePdfLabel,
section: "top" as const,
order: 60,
disabled: activeFileCount === 0,
visible: activeFileCount > 0,
onClick: closePdf,
},
];
}, [
t,
@@ -177,7 +160,6 @@ export function usePageEditorRightRailButtons(
deleteSelectedLabel,
exportSelectedLabel,
saveChangesLabel,
closePdfLabel,
totalPages,
selectedPageCount,
csvInput,
@@ -191,8 +173,6 @@ export function usePageEditorRightRailButtons(
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,
]);
useRightRailButtons(buttons);
@@ -207,7 +207,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
)}
</Text>
<Text size="sm" c="dimmed">
{t("storageShare.fileCount", "{{count}} files selected", {
{t("storageShare.fileCount", "{{count}} files", {
count: files.length,
})}
</Text>
@@ -116,7 +116,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
)}
</Text>
<Text size="sm" c="dimmed">
{t("storageUpload.fileCount", "{{count}} files selected", {
{t("storageUpload.fileCount", "{{count}} files", {
count: files.length,
})}
</Text>
@@ -0,0 +1,79 @@
import React from "react";
interface CloudIconProps extends React.SVGProps<SVGSVGElement> {
colored?: boolean;
}
/**
* Google Drive icon with brand-color hover support.
* Pass `colored={true}` to render the official tri-color logo;
* omit / pass `colored={false}` for a uniform muted/current-color version.
*/
export function GoogleDriveIcon({ colored, ...rest }: CloudIconProps) {
return (
<svg viewBox="0 0 87.3 78" width={18} height={18} {...rest}>
<path
d="M6.6 66.85l3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8H0c0 1.55.4 3.1 1.2 4.5z"
fill={colored ? "#0066DA" : "currentColor"}
opacity={colored ? 1 : 0.5}
/>
<path
d="M43.65 25L29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3L1.2 52.35c-.8 1.4-1.2 2.95-1.2 4.5h27.5z"
fill={colored ? "#00AC47" : "currentColor"}
opacity={colored ? 1 : 0.4}
/>
<path
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5H59.85L73.55 76.8z"
fill={colored ? "#EA4335" : "currentColor"}
opacity={colored ? 1 : 0.5}
/>
<path
d="M43.65 25L57.4 1.2c-1.35-.8-2.9-1.2-4.5-1.2H34.4c-1.6 0-3.15.45-4.5 1.2z"
fill={colored ? "#00832D" : "currentColor"}
opacity={colored ? 1 : 0.45}
/>
<path
d="M59.85 53H27.5l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
fill={colored ? "#2684FC" : "currentColor"}
opacity={colored ? 1 : 0.55}
/>
<path
d="M73.4 26.5L60.7 4.5c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25l16.2 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
fill={colored ? "#FFBA00" : "currentColor"}
opacity={colored ? 1 : 0.5}
/>
</svg>
);
}
/**
* OneDrive icon with brand-color hover support.
* FOR FUTURE USE — OneDrive integration is not yet implemented.
*/
export function OneDriveIcon({ colored, ...rest }: CloudIconProps) {
return (
<svg viewBox="0 0 24 24" width={18} height={18} {...rest}>
<path
d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"
fill={colored ? "#0078D4" : "currentColor"}
opacity={colored ? 1 : 0.5}
/>
</svg>
);
}
/**
* Dropbox icon with brand-color hover support.
* FOR FUTURE USE — Dropbox integration is not yet implemented.
*/
export function DropboxIcon({ colored, ...rest }: CloudIconProps) {
return (
<svg viewBox="0 0 16 16" width={18} height={18} {...rest}>
<path
d="M8.01 4.555L4.005 7.11 8.01 9.665 4.005 12.22 0 9.651l4.005-2.555L0 4.555 4.005 2 8.01 4.555zm-4.026 8.487l4.006-2.555 4.005 2.555-4.005 2.555-4.006-2.555zm4.026-3.39l4.005-2.556L8.01 4.555 11.995 2 16 4.555l-4.005 2.555L16 9.665l-4.005 2.555L8.01 9.652z"
fill={colored ? "#0061FF" : "currentColor"}
opacity={colored ? 1 : 0.5}
/>
</svg>
);
}
@@ -7,21 +7,18 @@ import {
Badge,
Button,
Box,
Image,
ThemeIcon,
ActionIcon,
Tooltip,
Loader,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { StirlingFileStub } from "@app/types/fileContext";
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
interface FileCardProps {
file: File;
@@ -47,15 +44,15 @@ const FileCard = ({
isSupported = true,
}: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const { thumbnail: indexedDBThumb, isGenerating } =
useIndexedDBThumbnail(fileStub);
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
const {
isEncrypted,
thumbnail: thumb,
isGenerating,
} = useFileThumbnail(fileStub);
const [isHovered, setIsHovered] = useState(false);
// Show loading state during hydration: PDF file without thumbnail yet
const isPdf = file.type === "application/pdf";
const isHydrating = isPdf && !thumb && !isGenerating;
const isHydrating = isPdf && !isEncrypted && !thumb && !isGenerating;
return (
<Card
@@ -73,8 +70,7 @@ const FileCard = ({
? "2px solid var(--mantine-color-blue-6)"
: undefined,
backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined,
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? "none" : "grayscale(50%)",
opacity: 1,
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
@@ -146,51 +142,13 @@ const FileCard = ({
)}
</div>
)}
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating || isHydrating ? (
<Stack align="center" justify="center" gap="xs">
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading...
</Text>
</Stack>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>
Large File
</Text>
)}
</div>
)}
<DocumentThumbnail
file={file}
thumbnail={thumb || undefined}
isEncrypted={isEncrypted}
isLoading={isGenerating || isHydrating}
iconSize="3rem"
/>
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
@@ -0,0 +1,204 @@
export type FileDocVariant =
| "pdf"
| "spreadsheet"
| "doc"
| "image"
| "archive"
| "code"
| "generic";
export const VARIANT_COLORS: Record<FileDocVariant, string> = {
pdf: "#DC2626",
spreadsheet: "#16a34a",
doc: "#2563eb",
image: "#7c3aed",
archive: "#ea580c",
code: "#0891b2",
generic: "#71717a",
};
export function FileDocIcon({
color,
variant,
className,
style,
}: {
color?: string;
variant: FileDocVariant;
className?: string;
style?: React.CSSProperties;
}) {
color ??= VARIANT_COLORS[variant];
const foldX = 10.5;
const foldY = 5.5;
let interior: React.ReactNode;
if (variant === "spreadsheet") {
// Outer border of the grid
const gx1 = 3,
gy1 = 8,
gx2 = 13,
gy2 = 17;
// Column divider x, row divider y positions
const colX = 8;
const row1Y = 11,
row2Y = 14;
interior = (
<g opacity="0.7" stroke={color} strokeWidth="1" fill="none">
<rect x={gx1} y={gy1} width={gx2 - gx1} height={gy2 - gy1} rx="0.5" />
<line x1={colX} y1={gy1} x2={colX} y2={gy2} />
<line x1={gx1} y1={row1Y} x2={gx2} y2={row1Y} />
<line x1={gx1} y1={row2Y} x2={gx2} y2={row2Y} />
</g>
);
} else if (variant === "image") {
interior = (
<>
<polyline
points="2.5,16 5.5,11.5 8,14 10,12 13.5,16"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
fill="none"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle
cx="11"
cy="9.5"
r="1.3"
stroke={color}
strokeWidth="1"
opacity="0.6"
fill="none"
/>
</>
);
} else if (variant === "code") {
interior = (
<>
<polyline
points="6,9.5 3.5,12.5 6,15.5"
stroke={color}
strokeWidth="1.3"
opacity="0.7"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="10,9.5 12.5,12.5 10,15.5"
stroke={color}
strokeWidth="1.3"
opacity="0.7"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
);
} else if (variant === "archive") {
interior = (
<>
<line
x1="5"
y1="9.5"
x2="11"
y2="9.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeDasharray="2,1.5"
strokeLinecap="round"
/>
<line
x1="5"
y1="12"
x2="11"
y2="12"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeDasharray="2,1.5"
strokeLinecap="round"
/>
<line
x1="5"
y1="14.5"
x2="11"
y2="14.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeDasharray="2,1.5"
strokeLinecap="round"
/>
</>
);
} else {
// pdf, doc, generic — text lines
interior = (
<>
<line
x1="3.5"
y1="9.5"
x2="12.5"
y2="9.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeLinecap="round"
/>
<line
x1="3.5"
y1="12"
x2="12.5"
y2="12"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeLinecap="round"
/>
<line
x1="3.5"
y1="14.5"
x2="8.5"
y2="14.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeLinecap="round"
/>
</>
);
}
return (
<svg
className={className}
style={{ width: 16, height: 20, ...style }}
viewBox="0 0 16 20"
fill="none"
aria-hidden="true"
>
{/* Document outline with top-right page fold */}
<path
d={`M 1.5,1 H ${foldX} L 15,${foldY} V 18.5 Q 15,19 14.5,19 H 1.5 Q 1,19 1,18.5 V 1.5 Q 1,1 1.5,1 Z`}
stroke={color}
strokeWidth="1.3"
fill="none"
strokeLinejoin="round"
/>
{/* Fold corner triangle */}
<path
d={`M ${foldX},1 L ${foldX},${foldY} L 15,${foldY} Z`}
stroke={color}
strokeWidth="1.3"
fill={color}
fillOpacity="0.2"
strokeLinejoin="round"
/>
{interior}
</svg>
);
}
@@ -8,13 +8,11 @@ import {
Checkbox,
ScrollArea,
Box,
Image,
Badge,
ThemeIcon,
SimpleGrid,
} from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { useTranslation } from "react-i18next";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import { FileId } from "@app/types/file";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
@@ -200,26 +198,23 @@ const FilePickerModal = ({
height: 80,
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "var(--mantine-color-gray-0)",
backgroundColor: "white",
flexShrink: 0,
overflow: "hidden",
}}
>
{file.thumbnail ? (
<Image
src={file.thumbnail}
alt="PDF thumbnail"
height={70}
width={50}
fit="contain"
/>
) : (
<ThemeIcon variant="light" color="red" size={40}>
<PictureAsPdfIcon style={{ fontSize: 24 }} />
</ThemeIcon>
)}
<DocumentThumbnail
file={file}
thumbnail={
file.processedFile?.isEncrypted
? undefined
: file.thumbnail
}
isEncrypted={Boolean(
file.processedFile?.isEncrypted,
)}
iconSize="2rem"
/>
</Box>
{/* File info */}
@@ -0,0 +1,283 @@
/* ── Trigger ─────────────────────────────────────── */
.trigger {
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
background: var(--bg-surface);
cursor: pointer;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
user-select: none;
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.trigger:hover:not(.triggerDisabled) {
border-color: var(--mantine-color-blue-5);
}
.triggerDisabled {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}
.triggerOpen {
border-color: var(--mantine-color-blue-5);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent);
}
/* ── Popover dropdown ────────────────────────────── */
.dropdown {
padding: 0;
overflow: hidden;
border-radius: var(--radius-md) !important;
min-width: 340px;
}
/* ── Header area (tabs + sort) ───────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.3rem 0.45rem;
gap: 0.4rem;
border-bottom: 1px solid var(--border-default);
background: var(--bg-surface);
}
.tabGroup {
display: flex;
align-items: stretch;
flex: 1;
min-width: 0;
}
/* One slim row: Saved files | Workbench | Upload (text only, no icon) */
.slimTabBar {
display: flex;
align-items: stretch;
flex: 1;
min-width: 0;
min-height: 24px;
max-height: 26px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: color-mix(
in srgb,
var(--mantine-color-default-hover) 85%,
transparent
);
padding: 1px;
gap: 1px;
box-sizing: border-box;
}
.slimTab,
.slimTabActive {
flex: 1 1 0;
min-width: 0;
margin: 0;
border: none;
cursor: pointer;
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.15;
letter-spacing: 0.01em;
padding: 1px 0.35rem;
border-radius: calc(var(--radius-sm) - 2px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
background 0.1s ease,
color 0.1s ease;
}
.slimTab {
background: transparent;
color: var(--mantine-color-dimmed);
}
.slimTab:hover {
color: var(--mantine-color-text);
background: color-mix(in srgb, var(--mantine-color-body) 60%, transparent);
}
.slimTabActive {
background: var(--mantine-color-blue-filled);
color: #fff;
}
.slimTabUpload {
flex: 0 0 auto;
margin: 0;
border: none;
cursor: pointer;
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.15;
padding: 1px 0.45rem;
border-radius: calc(var(--radius-sm) - 2px);
white-space: nowrap;
background: transparent;
color: var(--mantine-color-dimmed);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
}
.slimTabUpload:hover:not(:disabled) {
color: var(--mantine-color-blue-filled);
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
}
.slimTabUpload:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Sort button group ───────────────────────────── */
.sortGroup {
display: flex;
gap: 2px;
background: var(--mantine-color-default-hover);
border-radius: var(--radius-sm);
padding: 2px;
}
.sortBtn {
padding: 2px 8px;
font-size: 0.7rem;
border-radius: calc(var(--radius-sm) - 2px);
cursor: pointer;
border: none;
background: transparent;
color: var(--mantine-color-dimmed);
line-height: 1.6;
transition:
background 0.1s,
color 0.1s;
white-space: nowrap;
}
.sortBtn:hover {
color: var(--mantine-color-text);
}
.sortBtnActive {
background: var(--mantine-color-blue-filled);
color: white;
}
.sortArrow {
opacity: 0.85;
font-size: 0.65rem;
vertical-align: middle;
}
/* ── Search / filter row ─────────────────────────── */
.searchRow {
padding: 0.3rem 0.45rem;
border-bottom: 1px solid var(--border-default);
background: var(--bg-surface);
}
.searchInput {
width: 100%;
box-sizing: border-box;
height: 1.625rem;
padding: 0 0.5rem;
font-size: 0.7rem;
border: 1px solid var(--border-subtle, var(--border-default));
border-radius: var(--radius-sm);
background: var(--mantine-color-default-hover);
color: var(--mantine-color-text);
outline: none;
transition: border-color 0.12s ease;
}
.searchInput::placeholder {
color: var(--mantine-color-dimmed);
}
.searchInput:focus {
border-color: var(--mantine-color-blue-5);
}
/* hide browser clear button in Safari */
.searchInput::-webkit-search-cancel-button {
-webkit-appearance: none;
}
/* ── Scrollable file list ────────────────────────── */
.list {
padding: 0;
}
/* ── Individual file row ─────────────────────────── */
.fileItem {
width: 100%;
padding: 0.625rem 0.875rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
cursor: pointer;
background: transparent;
border: none;
border-top: 1px solid var(--border-default);
text-align: left;
transition: background 0.08s ease;
}
.fileItem:first-child {
border-top: none;
}
.fileItem:hover:not(:disabled) {
background: var(--mantine-color-default-hover);
}
.fileItem:disabled {
cursor: wait;
}
.fileItemContent {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.fileName {
font-size: 0.8125rem;
font-weight: 500;
color: var(--mantine-color-text);
}
.fileMeta {
font-size: 0.6875rem;
color: var(--mantine-color-dimmed);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Empty / loading states ──────────────────────── */
.emptyState {
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1rem;
flex-direction: column;
gap: 0.5rem;
color: var(--mantine-color-dimmed);
}
@@ -0,0 +1,632 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Box, Popover, ScrollArea, Text, Loader } from "@mantine/core";
import AddIcon from "@mui/icons-material/Add";
import { useTranslation } from "react-i18next";
import {
createStirlingFile,
createFileId,
createNewStirlingFileStub,
} from "@app/types/fileContext";
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { useAllFiles } from "@app/contexts/FileContext";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useFileManager } from "@app/hooks/useFileManager";
import { fileStorage } from "@app/services/fileStorage";
import apiClient from "@app/services/apiClient";
import {
parseContentDispositionFilename,
extractLatestFilesFromBundle,
} from "@app/services/shareBundleUtils";
import { truncateCenter } from "@app/utils/textUtils";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import styles from "@app/components/shared/FileSelectorPicker.module.css";
import "@app/components/shared/FileSidebarFileItem.css";
const LS_TAB = "filePicker.tab";
const LS_SORT = "filePicker.sort";
const LS_SORT_DIR = "filePicker.sortDir";
function lsGet<const T extends readonly string[]>(
key: string,
fallback: T[number],
valid: T,
): T[number] {
try {
const v = localStorage.getItem(key);
const allowed = valid as readonly string[];
if (v && allowed.includes(v)) return v as T[number];
} catch {
/* ignore */
}
return fallback;
}
function lsSet(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
function formatBytes(bytes: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(ms: number | undefined): string {
if (!ms) return "";
return new Date(ms).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function buildMeta(stub: StirlingFileStub): string {
const parts: string[] = [];
const pages = stub.processedFile?.totalPages;
if (pages) parts.push(`${pages} ${pages === 1 ? "page" : "pages"}`);
const size = formatBytes(stub.size ?? 0);
if (size) parts.push(size);
const date = formatDate(stub.lastModified || stub.createdAt);
if (date) parts.push(date);
return parts.join(" · ");
}
export interface FileSelectorResult {
stub: StirlingFileStub;
stirlingFile: StirlingFile;
}
export interface FileSelectorPickerProps {
placeholder?: string;
/** FileIds to hide from both lists (e.g. the other slot's current selection) */
excludeIds?: string[];
disabled?: boolean;
/** Optional data-testid applied to the trigger box */
testId?: string;
/**
* Called with the stub (for display) and the ready-to-use StirlingFile (for processing).
* Files are NOT added to the workbench — data is loaded inline.
*/
onSelect: (result: FileSelectorResult) => void;
}
export function FileSelectorPicker({
placeholder,
excludeIds = [],
disabled = false,
testId,
onSelect,
}: FileSelectorPickerProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"workbench" | "saved">(() =>
lsGet(LS_TAB, "saved", ["workbench", "saved"]),
);
const [sortBy, setSortBy] = useState<"date" | "name">(() =>
lsGet(LS_SORT, "date", ["date", "name"]),
);
const [sortDir, setSortDir] = useState<"asc" | "desc">(() =>
lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]),
);
const [savedStubs, setSavedStubs] = useState<StirlingFileStub[]>([]);
const [savedLoading, setSavedLoading] = useState(false);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [hoveredStub, setHoveredStub] = useState<{
rect: DOMRect;
stub: StirlingFileStub;
} | null>(null);
const [hoveredThumbnail, setHoveredThumbnail] = useState<string | null>(null);
const thumbCancelRef = useRef<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { fileStubs: workbenchStubs } = useAllFiles();
const indexedDB = useIndexedDB();
const { selectors } = useFileContext();
const { loadRecentFiles } = useFileManager();
// Load thumbnail lazily when hovering over a file row
useEffect(() => {
if (!hoveredStub) {
setHoveredThumbnail(null);
return;
}
if (hoveredStub.stub.thumbnailUrl) {
setHoveredThumbnail(hoveredStub.stub.thumbnailUrl);
return;
}
thumbCancelRef.current = false;
setHoveredThumbnail(null);
(async () => {
try {
const file = await indexedDB.loadFile(hoveredStub.stub.id as FileId);
if (!file || thumbCancelRef.current) return;
const thumbnail = await generateThumbnailForFile(file);
if (thumbCancelRef.current || !thumbnail) return;
setHoveredThumbnail(thumbnail);
void indexedDB.updateThumbnail(
hoveredStub.stub.id as FileId,
thumbnail,
);
} catch {
// non-critical
}
})();
return () => {
thumbCancelRef.current = true;
};
}, [hoveredStub, indexedDB]);
const handleTabChange = useCallback((tab: "workbench" | "saved") => {
lsSet(LS_TAB, tab);
setActiveTab(tab);
}, []);
const handleSortChange = useCallback(
(sort: "date" | "name") => {
if (sort === sortBy) {
const newDir = sortDir === "asc" ? "desc" : "asc";
lsSet(LS_SORT_DIR, newDir);
setSortDir(newDir);
} else {
const defaultDir: "asc" | "desc" = sort === "name" ? "asc" : "desc";
lsSet(LS_SORT, sort);
lsSet(LS_SORT_DIR, defaultDir);
setSortBy(sort);
setSortDir(defaultDir);
}
},
[sortBy, sortDir],
);
// Sync tab/sort from localStorage whenever this picker opens.
// Both slot pickers mount simultaneously so their useState initialisers run at
// the same time — a tab change in one picker writes to localStorage but the
// other picker's React state is stale. Re-reading on open fixes that.
useEffect(() => {
if (!isOpen) return;
setActiveTab(lsGet(LS_TAB, "saved", ["workbench", "saved"]));
setSortBy(lsGet(LS_SORT, "date", ["date", "name"]));
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
}, [isOpen]);
// Load saved files when the saved tab is active and the picker is open
useEffect(() => {
if (activeTab !== "saved" || !isOpen) return;
setSavedLoading(true);
loadRecentFiles()
.then(setSavedStubs)
.finally(() => setSavedLoading(false));
}, [activeTab, isOpen, loadRecentFiles]);
const workbenchIdSet = useMemo(
() => new Set(workbenchStubs.map((s) => s.id)),
[workbenchStubs],
);
const displayStubs = useMemo(() => {
const base = activeTab === "workbench" ? workbenchStubs : savedStubs;
const q = searchQuery.trim().toLowerCase();
const filtered = base.filter(
(s) =>
!excludeIds.includes(s.id) && (!q || s.name.toLowerCase().includes(q)),
);
const dir = sortDir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) =>
sortBy === "name"
? dir * a.name.localeCompare(b.name)
: dir *
((a.lastModified || a.createdAt || 0) -
(b.lastModified || b.createdAt || 0)),
);
}, [
activeTab,
workbenchStubs,
savedStubs,
excludeIds,
sortBy,
sortDir,
searchQuery,
]);
const loadAndSelect = useCallback(
async (stub: StirlingFileStub) => {
if (loadingId) return;
// Workbench file — get StirlingFile directly from FileContext (no loading needed)
if (workbenchIdSet.has(stub.id)) {
const sf = selectors.getFile(stub.id as FileId);
if (sf) {
// Prefer the workbench stub (has thumbnail) over the saved stub (may not)
const workbenchStub =
selectors.getStirlingFileStub(stub.id as FileId) ?? stub;
onSelect({ stub: workbenchStub, stirlingFile: sf });
setIsOpen(false);
}
return;
}
// Saved file — load bytes without touching the workbench
setLoadingId(stub.id);
try {
let stirlingFile: StirlingFile | null = null;
if (stub.remoteShareToken) {
const res = await apiClient.get(
`/api/v1/storage/share-links/${stub.remoteShareToken}`,
{
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
);
const ct =
res.headers?.["content-type"] ||
res.headers?.["Content-Type"] ||
"";
const disp =
res.headers?.["content-disposition"] ||
res.headers?.["Content-Disposition"] ||
"";
const files = await extractLatestFilesFromBundle(
res.data as Blob,
parseContentDispositionFilename(disp) || "shared-file",
ct,
);
if (files[0])
stirlingFile = createStirlingFile(files[0], createFileId());
} else if (stub.remoteStorageId) {
const res = await apiClient.get(
`/api/v1/storage/files/${stub.remoteStorageId}/download`,
{
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
);
const ct =
res.headers?.["content-type"] ||
res.headers?.["Content-Type"] ||
"";
const disp =
res.headers?.["content-disposition"] ||
res.headers?.["Content-Disposition"] ||
"";
const files = await extractLatestFilesFromBundle(
res.data as Blob,
parseContentDispositionFilename(disp) || stub.name,
ct,
);
if (files[0])
stirlingFile = createStirlingFile(files[0], stub.id as FileId);
} else {
// Local IndexedDB file
const localFile = await fileStorage.getStirlingFile(stub.id);
if (localFile) stirlingFile = localFile;
}
if (stirlingFile) {
// Generate thumbnail on-the-fly if the stub doesn't already have one
let resolvedStub = stub;
if (!resolvedStub.thumbnailUrl) {
try {
const thumbnail = await generateThumbnailForFile(stirlingFile);
if (thumbnail) {
resolvedStub = { ...stub, thumbnailUrl: thumbnail };
// Persist so subsequent opens don't regenerate
void fileStorage.updateThumbnail(
stirlingFile.fileId as FileId,
thumbnail,
);
}
} catch {
// Non-fatal — thumbnail simply won't show
}
}
onSelect({ stub: resolvedStub, stirlingFile });
setIsOpen(false);
}
} catch (err) {
console.error("FileSelectorPicker: failed to load file", err);
} finally {
setLoadingId(null);
}
},
[loadingId, workbenchIdSet, selectors, onSelect],
);
const handleUpload = useCallback(
async (file: File | null) => {
if (!file || uploadBusy || disabled) return;
setUploadBusy(true);
try {
const id = createFileId();
let stub = createNewStirlingFileStub(file, id);
const stirlingFile = createStirlingFile(file, id);
// Generate a first-page thumbnail for the uploaded file
try {
const thumbnail = await generateThumbnailForFile(file);
if (thumbnail) stub = { ...stub, thumbnailUrl: thumbnail };
} catch {
// Non-fatal — thumbnail simply won't show
}
await fileStorage.storeStirlingFile(stirlingFile, stub);
lsSet(LS_TAB, "saved");
setActiveTab("saved");
const refreshed = await loadRecentFiles();
setSavedStubs(refreshed);
onSelect({ stub, stirlingFile });
setIsOpen(false);
} catch (err) {
console.error("FileSelectorPicker: upload failed", err);
} finally {
setUploadBusy(false);
}
},
[disabled, loadRecentFiles, onSelect, uploadBusy],
);
const triggerClass = [
styles.trigger,
disabled ? styles.triggerDisabled : "",
isOpen ? styles.triggerOpen : "",
]
.filter(Boolean)
.join(" ");
return (
<>
<Popover
opened={isOpen}
onChange={setIsOpen}
onClose={() => {
setIsOpen(false);
setSearchQuery("");
}}
position="bottom-start"
withinPortal
shadow="md"
closeOnClickOutside
clickOutsideEvents={["mousedown", "touchstart"]}
>
<Popover.Target>
<Box
className={triggerClass}
style={{ width: "100%" }}
data-testid={testId}
onClick={() => {
if (!disabled) setIsOpen((o) => !o);
}}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if (!disabled && (e.key === "Enter" || e.key === " "))
setIsOpen((o) => !o);
}}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<Text
size="sm"
c="dimmed"
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{placeholder ||
t("fileSelectorPicker.placeholder", "Select file")}
</Text>
<AddIcon
style={{
fontSize: 18,
color: "var(--mantine-color-dimmed)",
flexShrink: 0,
}}
/>
</Box>
</Popover.Target>
<Popover.Dropdown className={styles.dropdown}>
<div className={styles.header}>
<div className={styles.tabGroup}>
<div
className={styles.slimTabBar}
role="group"
aria-label={t(
"fileSelectorPicker.tabListLabel",
"Saved files, workbench, upload",
)}
>
<button
type="button"
aria-pressed={activeTab === "saved"}
className={
activeTab === "saved"
? styles.slimTabActive
: styles.slimTab
}
onClick={() => handleTabChange("saved")}
>
{t("fileSelectorPicker.tabs.saved", "Saved files")}
</button>
<button
type="button"
aria-pressed={activeTab === "workbench"}
className={
activeTab === "workbench"
? styles.slimTabActive
: styles.slimTab
}
onClick={() => handleTabChange("workbench")}
>
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
</button>
<button
type="button"
data-testid="file-selector-upload-btn"
className={styles.slimTabUpload}
aria-label={t(
"fileSelectorPicker.upload",
"Upload from computer",
)}
title={t("fileSelectorPicker.upload", "Upload from computer")}
disabled={disabled || uploadBusy}
onClick={() => fileInputRef.current?.click()}
>
{uploadBusy ? (
<Loader size={11} />
) : (
t("fileSelectorPicker.tabs.upload", "Upload")
)}
</button>
</div>
</div>
<div className={styles.sortGroup}>
<button
type="button"
className={[
styles.sortBtn,
sortBy === "date" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("date")}
title={
sortDir === "desc"
? t("fileSelectorPicker.sort.dateDesc", "Newest first")
: t("fileSelectorPicker.sort.dateAsc", "Oldest first")
}
>
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
{sortBy === "date" && (
<span className={styles.sortArrow}>
{sortDir === "desc" ? " ↓" : " ↑"}
</span>
)}
</button>
<button
type="button"
className={[
styles.sortBtn,
sortBy === "name" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("name")}
title={
sortDir === "asc"
? t("fileSelectorPicker.sort.nameAsc", "A to Z")
: t("fileSelectorPicker.sort.nameDesc", "Z to A")
}
>
{t("fileSelectorPicker.sort.nameLabel", "AZ")}
{sortBy === "name" && (
<span className={styles.sortArrow}>
{sortDir === "asc" ? " ↑" : " ↓"}
</span>
)}
</button>
</div>
</div>
<div className={styles.searchRow}>
<input
className={styles.searchInput}
type="search"
placeholder={t("fileSelectorPicker.search", "Filter files…")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
<ScrollArea h={260} className={styles.list}>
{savedLoading ? (
<div className={styles.emptyState}>
<Loader size="sm" />
</div>
) : displayStubs.length === 0 ? (
<div className={styles.emptyState}>
<Text size="sm">
{t("fileSelectorPicker.empty", "No files available")}
</Text>
</div>
) : (
displayStubs.map((stub) => {
const meta = buildMeta(stub);
const isItemLoading = loadingId === stub.id;
return (
<button
key={stub.id}
type="button"
className={styles.fileItem}
onClick={() => void loadAndSelect(stub)}
disabled={!!loadingId}
onMouseEnter={(e) =>
setHoveredStub({
rect: e.currentTarget.getBoundingClientRect(),
stub,
})
}
onMouseLeave={() => setHoveredStub(null)}
>
<div className={styles.fileItemContent}>
<span className={styles.fileName} title={stub.name}>
{truncateCenter(stub.name, 48)}
</span>
{meta && <span className={styles.fileMeta}>{meta}</span>}
</div>
{isItemLoading && <Loader size="xs" />}
</button>
);
})
)}
</ScrollArea>
{hoveredStub &&
hoveredThumbnail &&
createPortal(
<div
className="file-sidebar-thumb-tooltip"
style={{
top: hoveredStub.rect.top + hoveredStub.rect.height / 2,
left: hoveredStub.rect.left - 170,
}}
>
<img
src={hoveredThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
</Popover.Dropdown>
</Popover>
{/* Hidden file input lives outside the Popover so it is always in the DOM.
Tests can target it directly with setInputFiles without opening the popover. */}
<input
ref={fileInputRef}
type="file"
data-testid={testId ? `${testId}-input` : "file-selector-upload-input"}
accept=".pdf,application/pdf"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
void handleUpload(file);
e.target.value = "";
}}
/>
</>
);
}
@@ -0,0 +1,411 @@
/* ========== FILE SIDEBAR ========== */
.file-sidebar {
background-color: var(--bg-toolbar);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
z-index: 10;
transition:
width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
}
.file-sidebar-inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ---- Header ---- */
.file-sidebar-header {
display: flex;
align-items: center;
height: 48px;
padding: 0 14px;
gap: 10px;
cursor: pointer;
border-radius: 4px;
margin: 4px 4px 0 4px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */
.file-sidebar-header:hover {
background-color: var(--bg-hover);
}
.file-sidebar-menu-icon {
color: var(--text-muted) !important;
font-size: 18px !important;
flex-shrink: 0;
}
.file-sidebar-brand-text {
font-family: "Alumni Sans", sans-serif;
font-weight: 700;
font-size: 18px;
letter-spacing: 0.3px;
color: var(--text-secondary);
white-space: nowrap;
}
/* ---- Search row ---- */
.file-sidebar-search-row {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 14px;
gap: 0;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.file-sidebar-search-row:not(.active):hover {
background-color: var(--bg-hover);
}
.file-sidebar-search-icon {
color: var(--text-muted) !important;
font-size: 18px !important;
flex-shrink: 0;
}
.file-sidebar-search-close {
cursor: pointer;
}
.file-sidebar-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: var(--text-primary);
margin-left: 12px;
min-width: 0;
}
.file-sidebar-search-input::placeholder {
color: var(--text-muted);
}
.file-sidebar-search-label {
margin-left: 12px;
font-size: 14px;
color: var(--text-secondary);
}
/* ---- Scrollable content ---- */
/* This is a flex column — action rows are fixed, only the file list scrolls */
.file-sidebar-scroll {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.file-sidebar-scroll::-webkit-scrollbar {
width: 4px;
}
.file-sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.file-sidebar-scroll::-webkit-scrollbar-thumb {
background: rgb(var(--border));
border-radius: 2px;
}
/* ---- Action rows (Open from Computer, etc.) ---- */
.file-sidebar-action-row {
display: flex;
align-items: center;
height: 32px;
padding: 0 14px;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
gap: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.file-sidebar-action-row:hover {
background-color: var(--bg-hover);
}
.file-sidebar-action-icon {
color: var(--text-muted) !important;
font-size: 18px !important;
flex-shrink: 0;
}
.file-sidebar-action-label {
margin-left: 12px;
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
}
/* ---- Cloud storage rows ---- */
.file-sidebar-cloud-row {
display: flex;
align-items: center;
height: 32px;
padding: 0 14px;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
gap: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.file-sidebar-cloud-row:not(.disabled):hover {
background-color: var(--bg-hover);
}
.file-sidebar-cloud-row.disabled {
cursor: default;
opacity: 0.5;
}
.file-sidebar-cloud-icon-wrapper {
position: relative;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.file-sidebar-cloud-icon-gray {
position: absolute;
inset: 0;
transition: opacity 0.2s ease;
opacity: 1;
}
.file-sidebar-cloud-icon-color {
position: absolute;
inset: 0;
transition: opacity 0.2s ease;
opacity: 0;
}
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-gray {
opacity: 0;
}
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-color {
opacity: 1;
}
/* ---- Files section ---- */
.file-sidebar-files-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-top: 4px;
}
.file-sidebar-file-list {
flex: 1;
min-height: 0;
overflow-y: scroll;
}
.file-sidebar-file-list::-webkit-scrollbar {
width: 4px;
}
.file-sidebar-file-list::-webkit-scrollbar-track {
background: transparent;
}
.file-sidebar-file-list::-webkit-scrollbar-thumb {
background: rgb(var(--border));
border-radius: 2px;
}
.file-sidebar-file-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.file-sidebar-section-divider {
border-top: 1px solid var(--border-subtle);
margin: 10px 14px 6px 14px;
}
.file-sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 6px 14px;
margin: 4px 0 0 0;
}
.file-sidebar-section-header {
position: relative;
border-top: 1px solid var(--border-subtle);
padding-top: 10px;
margin-top: 4px;
flex-shrink: 0;
}
.file-sidebar-section-label {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
text-transform: uppercase;
}
.file-sidebar-section-count {
font-size: 11px;
color: var(--text-muted);
}
.file-sidebar-section-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
padding: 0;
outline: none;
transition:
background-color 0.12s ease,
color 0.12s ease,
opacity 0.12s ease;
}
.file-sidebar-section-btn:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 1px;
}
.file-sidebar-section-btn:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.file-sidebar-section-btn-external {
opacity: 0;
pointer-events: none;
}
.file-sidebar-section-header:hover .file-sidebar-section-btn-external {
opacity: 1;
pointer-events: auto;
}
.file-sidebar-section-btn-add {
margin-left: auto;
}
/* ---- Sidebar content fade (for collapsed state) ---- */
/* Elements are conditionally rendered so use animation (not transition) to fade in from 0.
Delay until the width animation is nearly done (0.18s of 0.22s) to avoid squashed-text effect. */
@keyframes sidebar-content-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sidebar-content-fade {
animation: sidebar-content-in 0.12s ease 0.18s both;
}
.file-sidebar[data-collapsed="true"] .sidebar-content-fade {
opacity: 0;
pointer-events: none;
animation: none;
}
/* ---- Bottom bar (user + settings) ---- */
.file-sidebar-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
min-height: 48px;
}
/* Bottom bar settings icon tracks the right edge during collapse animation */
.file-sidebar-bottom-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--mantine-color-blue-6, #3b82f6);
color: #fff;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
}
.file-sidebar-bottom-name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.file-sidebar-bottom-bar[role="button"]:hover {
background-color: var(--bg-hover);
}
.file-sidebar-bottom-bar[role="button"]:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.file-sidebar-bottom-settings {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-muted);
padding: 0;
flex-shrink: 0;
margin-left: auto;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
width: 32px;
height: 32px;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
justify-content: center;
padding: 8px 0;
}
@@ -0,0 +1,589 @@
import React, {
useState,
useCallback,
useRef,
useEffect,
forwardRef,
} from "react";
import { Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
import {
useNavigationState,
useNavigationActions,
useNavigationGuard,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import "@app/components/shared/FileSidebar.css";
const COLLAPSED_WIDTH = "3.5rem";
const EXPANDED_WIDTH = "16.25rem"; // ~260px
export interface FileSidebarProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
onOpenSettings?: () => void;
}
const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
function FileSidebar(
{ collapsed = false, onToggleCollapse, onOpenSettings },
ref,
) {
const { t } = useTranslation();
const [searchActive, setSearchActive] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const nativeFileInputRef = useRef<HTMLInputElement>(null);
// State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates.
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
null,
);
const { openFilesModal } = useFilesModalContext();
const { config } = useAppConfig();
const {
isEnabled: isGoogleDriveEnabled,
openPicker: openGoogleDrivePicker,
} = useGoogleDrivePicker();
const { state } = useFileState();
const { actions: fileActions } = useFileActions();
const { actions: navActions } = useNavigationActions();
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
const isMultiTool =
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
const { requestNavigation } = useNavigationGuard();
const { activeFileId, setActiveFileId } = useViewer();
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
const [displayName, setDisplayName] = useState<string>("Guest");
useEffect(() => {
if (!config?.enableLogin) return;
accountService
.getAccountData()
.then((data) => {
if (data?.username) setDisplayName(data.username);
})
.catch(() => {
/* not logged in or security disabled */
});
}, [config?.enableLogin]);
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
const [stubsLoaded, setStubsLoaded] = useState(false);
const refreshStubs = useCallback(async () => {
// Leaf files from IDB — same source as the file selection modal.
const stubs = await indexedDB.loadLeafMetadata();
const idbIds = new Set(stubs.map((s) => s.id as string));
// Also include workbench files not yet flushed to IDB.
const pendingStubs = state.files.ids
.map((id) => state.files.byId[id])
.filter(
(stub): stub is NonNullable<typeof stub> =>
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
);
const allStubs = [...stubs, ...pendingStubs];
setAllFileStubs(
allStubs.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)),
);
setStubsLoaded(true);
}, [indexedDB, state.files.ids, state.files.byId]);
// Refresh on mount, workbench changes, or external IndexedDB writes
useEffect(() => {
refreshStubs();
}, [refreshStubs, indexedDB.revision]);
// Once a pending file lands in state, open it in the viewer.
useEffect(() => {
if (!pendingViewFileId) return;
const isInWorkbench = state.files.ids.some(
(id) => (id as string) === pendingViewFileId,
);
if (isInWorkbench) {
setPendingViewFileId(null);
setActiveFileId(pendingViewFileId);
navActions.setWorkbench("viewer");
}
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
const filteredFileStubs = searchQuery.trim()
? allFileStubs.filter((stub) =>
stub.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: allFileStubs;
// Handle search activation
const handleSearchClick = useCallback(() => {
if (collapsed && onToggleCollapse) {
onToggleCollapse();
}
setSearchActive(true);
}, [collapsed, onToggleCollapse]);
const handleSearchClose = useCallback(() => {
setSearchActive(false);
setSearchQuery("");
}, []);
useEffect(() => {
if (searchActive && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchActive]);
// Handle Google Drive
const handleGoogleDriveClick = useCallback(async () => {
if (!isGoogleDriveEnabled) return;
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
}
}, [
isGoogleDriveEnabled,
openGoogleDrivePicker,
addFiles,
navActions,
isMultiTool,
]);
// Toggle file in/out of workbench
const handleFileClick = useCallback(
async (fileId: FileId) => {
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
);
if (workbenchFileId) {
// If this is the file currently open in the viewer, route through the
// navigation guard so the save modal fires when there are unsaved changes.
const isCurrentlyViewed = workbenchFileId === viewedWorkbenchId;
if (isCurrentlyViewed) {
requestNavigation(() => {
void fileActions.removeFiles([workbenchFileId], false);
});
return;
}
await fileActions.removeFiles([workbenchFileId], false);
} else {
// Re-add by stub to preserve its ID — addFiles() would create a new UUID + IDB entry.
const workbenchCount = state.files.ids.length;
if (workbenchCount > 0 && currentWorkbench === "viewer") {
navActions.setWorkbench("fileEditor");
}
await fileActions.addStirlingFileStubs([stub]);
if (isMultiTool) {
fileActions.setSelectedFiles([
...state.ui.selectedFileIds,
stub.id,
]);
} else {
if (workbenchCount === 0) {
navActions.setWorkbench("viewer");
} else {
navActions.setWorkbench("fileEditor");
}
}
}
},
[
allFileStubs,
state.files.ids,
state.ui.selectedFileIds,
fileActions,
navActions,
currentWorkbench,
activeFileId,
requestNavigation,
isMultiTool,
],
);
// Which file is currently open in the viewer — stable ID, never index-derived.
const viewedWorkbenchId =
currentWorkbench === "viewer" ? activeFileId : null;
const handleEyeClick = useCallback(
async (fileId: FileId, _e: React.MouseEvent) => {
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;
const isCurrentlyViewed = !!(
viewedWorkbenchId &&
(viewedWorkbenchId as string) === (stub.id as string)
);
if (isCurrentlyViewed) {
// Closing the currently-viewed file — guard against unsaved changes.
navActions.setWorkbench("fileEditor");
return;
}
// Switching to a different file while viewer is open — guard against unsaved changes.
const performSwitch = async () => {
const alreadyInWorkbench = state.files.ids.some(
(id) => (id as string) === (stub.id as string),
);
if (!alreadyInWorkbench) {
// Leave viewer before mutating workbench (prevents PSPDFKit crash).
if (state.files.ids.length > 0 && currentWorkbench === "viewer") {
navActions.setWorkbench("fileEditor");
}
await fileActions.addStirlingFileStubs([stub]);
}
// Route through pendingViewFileId so both setActiveFileIndex + setWorkbench fire together.
setPendingViewFileId(stub.id as string);
};
if (currentWorkbench === "viewer" && viewedWorkbenchId) {
requestNavigation(() => {
void performSwitch();
});
} else {
await performSwitch();
}
},
[
allFileStubs,
viewedWorkbenchId,
state.files.ids,
fileActions,
navActions,
currentWorkbench,
setPendingViewFileId,
requestNavigation,
],
);
const handleNativeFilePick = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) {
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(
files.length === 1 ? "viewer" : "fileEditor",
);
}
}
e.target.value = "";
},
[addFiles, navActions, isMultiTool],
);
const shouldHideGoogleDrive =
!isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
return (
<div
ref={ref}
className="file-sidebar"
style={{ width, minWidth: width, maxWidth: width }}
data-collapsed={collapsed}
data-sidebar="file-sidebar"
>
<div className="file-sidebar-inner">
{/* Header: hamburger + branding */}
<div
className="file-sidebar-header"
onClick={() => onToggleCollapse?.()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()}
aria-label={
collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar")
}
>
<MenuIcon className="file-sidebar-menu-icon" />
{!collapsed && (
<span className="file-sidebar-brand-text sidebar-content-fade">
Stirling PDF
</span>
)}
</div>
{/* Search row */}
{
<div
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
onClick={!searchActive ? handleSearchClick : undefined}
role={!searchActive ? "button" : undefined}
tabIndex={!searchActive ? 0 : undefined}
onKeyDown={
!searchActive
? (e) => e.key === "Enter" && handleSearchClick()
: undefined
}
>
{searchActive && !collapsed ? (
<CloseIcon
className="file-sidebar-search-icon"
onClick={(e) => {
e.stopPropagation();
handleSearchClose();
}}
/>
) : (
<SearchIcon className="file-sidebar-search-icon" />
)}
{!collapsed &&
(searchActive ? (
<input
ref={searchInputRef}
className="file-sidebar-search-input sidebar-content-fade"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t(
"fileSidebar.searchPlaceholder",
"Search files...",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-sidebar-search-label sidebar-content-fade">
{t("fileSidebar.search", "Search")}
</span>
))}
</div>
}
{/* Scrollable content */}
<div className="file-sidebar-scroll">
{/* Open from Computer + Google Drive */}
{
<>
<div
className="file-sidebar-action-row"
data-testid="files-button"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
openFilesModal();
}}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openFilesModal()}
>
<FolderOpenIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.openFromComputer", "Open from computer")}
</span>
)}
</div>
{!shouldHideGoogleDrive && (
<div
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
onClick={handleGoogleDriveClick}
role="button"
tabIndex={isGoogleDriveEnabled ? 0 : -1}
aria-disabled={!isGoogleDriveEnabled}
title={
!isGoogleDriveEnabled
? t(
"fileSidebar.googleDriveDisabled",
"Google Drive is not configured",
)
: t("fileSidebar.googleDrive", "Open from Google Drive")
}
>
<div className="file-sidebar-cloud-icon-wrapper">
<GoogleDriveIcon
className="file-sidebar-cloud-icon-gray"
style={{ color: "var(--text-secondary)" }}
/>
{isGoogleDriveEnabled && (
<GoogleDriveIcon
colored
className="file-sidebar-cloud-icon-color"
/>
)}
</div>
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.googleDrive", "Google Drive")}
</span>
)}
</div>
)}
</>
}
{/* Files section - always visible when expanded */}
{!collapsed && (
<div className="file-sidebar-files-section sidebar-content-fade">
<div className="file-sidebar-section-header">
<span className="file-sidebar-section-label">
{t("fileSidebar.files", "Files")}
</span>
<button
className="file-sidebar-section-btn file-sidebar-section-btn-external"
onClick={() => openFilesModal()}
title={t(
"fileSidebar.openFileManager",
"Open file manager",
)}
type="button"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
</button>
<button
className="file-sidebar-section-btn file-sidebar-section-btn-add"
onClick={() => nativeFileInputRef.current?.click()}
title={t("fileSidebar.addFiles", "Add files")}
type="button"
>
<AddIcon sx={{ fontSize: "1rem" }} />
</button>
<input
ref={nativeFileInputRef}
type="file"
multiple
accept=".pdf"
style={{ display: "none" }}
onChange={handleNativeFilePick}
/>
</div>
{!stubsLoaded ? (
<div className="file-sidebar-loading">
<Loader size="sm" color="var(--text-muted)" />
</div>
) : filteredFileStubs.length > 0 ? (
<div className="file-sidebar-file-list">
{filteredFileStubs.map((stub) => {
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
);
const isInWorkbench = !!workbenchFileId;
// Both active and viewed-in-viewer are ID-based — never index-based.
const isViewedInViewer = !!(
viewedWorkbenchId &&
viewedWorkbenchId === (stub.id as string)
);
const isActive = isViewedInViewer;
// In-memory thumbnail may be fresher than the IndexedDB stub.
// Encrypted files never get a raster thumbnail — use undefined
// so the sidebar icon is shown instead of a stale canvas thumbnail.
const isEncryptedFile =
stub.processedFile?.isEncrypted === true;
const thumbnailUrl = isEncryptedFile
? undefined
: (workbenchFileId
? state.files.byId[workbenchFileId]?.thumbnailUrl
: undefined) || stub.thumbnailUrl;
return (
<FileItem
key={stub.id}
fileId={stub.id}
name={stub.name}
size={stub.size}
lastModified={stub.lastModified}
isSelected={isInWorkbench}
isActive={isActive}
isViewedInViewer={isViewedInViewer}
thumbnailUrl={thumbnailUrl}
onClick={handleFileClick}
onEyeClick={handleEyeClick}
/>
);
})}
</div>
) : (
!searchActive && (
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t("fileSidebar.dropHint", "Open files to get started")}
</p>
</div>
)
)}
</div>
)}
</div>
</div>
{/* Bottom bar: user name + settings */}
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: undefined
}
title={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: undefined
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div className="file-sidebar-bottom-avatar" title={displayName}>
{displayName.charAt(0).toUpperCase()}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</div>
);
},
);
export default FileSidebar;
@@ -0,0 +1,283 @@
/* ---- File list ---- */
.file-sidebar-file-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 8px 0 8px;
display: flex;
flex-direction: column;
gap: 3px;
}
.file-sidebar-file-list::-webkit-scrollbar {
width: 4px;
}
.file-sidebar-file-list::-webkit-scrollbar-track {
background: transparent;
}
.file-sidebar-file-list::-webkit-scrollbar-thumb {
background: rgb(var(--border));
border-radius: 2px;
}
/* ---- File item ---- */
.file-sidebar-file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.file-sidebar-file-item:hover:not(.selected) {
background-color: rgba(59, 130, 246, 0.06);
}
.file-sidebar-file-item.selected {
background-color: rgba(59, 130, 246, 0.12);
}
.file-sidebar-file-item.active:not(.selected) {
background-color: rgba(59, 130, 246, 0.06);
}
/* Icon wrapper */
.file-sidebar-file-icon-wrapper {
position: relative;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.file-sidebar-file-icon-hover-hide {
display: block;
}
.file-sidebar-file-item:hover .file-sidebar-file-icon-hover-hide {
display: none;
}
.file-sidebar-file-checkbox-hover {
display: none;
width: 20px;
height: 20px;
border-radius: 5px;
border: 1.5px solid var(--border-hover, #52525b);
}
.file-sidebar-file-item:hover .file-sidebar-file-checkbox-hover {
display: block;
}
.file-sidebar-file-item.selected .file-sidebar-file-checkbox-hover {
display: none;
}
.file-sidebar-file-icon {
position: absolute;
inset: 0;
}
/* Checked state */
.file-sidebar-file-check {
width: 20px;
height: 20px;
border-radius: 5px;
background-color: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.file-sidebar-check-svg {
color: white;
flex-shrink: 0;
}
/* File info */
.file-sidebar-file-info {
flex: 1;
min-width: 0;
}
.file-sidebar-file-name {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-sidebar-file-item.selected .file-sidebar-file-name {
color: #3b82f6;
}
.file-sidebar-file-meta {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* ---- Eye button (open in viewer) ---- */
.file-sidebar-eye-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
padding: 0;
opacity: 0;
transition:
opacity 0.12s ease,
color 0.12s ease;
}
/* Show eye on hover (for any non-viewed file) */
.file-sidebar-file-item:hover .file-sidebar-eye-btn {
opacity: 1;
}
/* Pin the viewed item when scrolled out of view in either direction */
.file-sidebar-file-item.viewed {
position: sticky;
top: 0;
bottom: 0;
z-index: 2;
/* solid base + green tint overlay so content behind doesn't bleed through */
background-color: var(--bg-toolbar);
background-image: linear-gradient(
rgba(34, 197, 94, 0.1),
rgba(34, 197, 94, 0.1)
);
}
.file-sidebar-file-item.viewed .file-sidebar-file-name {
color: #22c55e;
}
.file-sidebar-file-item.viewed .file-sidebar-file-check {
background-color: #22c55e;
}
/* viewed takes precedence over selected */
.file-sidebar-file-item.viewed.selected {
background-color: var(--bg-toolbar);
background-image: linear-gradient(
rgba(34, 197, 94, 0.1),
rgba(34, 197, 94, 0.1)
);
}
/* Always show eye for the currently viewed file */
.file-sidebar-file-item.viewed .file-sidebar-eye-btn {
opacity: 1;
color: #22c55e;
}
.file-sidebar-eye-btn:hover {
color: var(--text-primary);
}
/* Eye open: shown by default */
.file-sidebar-eye-open {
display: block !important;
}
.file-sidebar-eye-closed {
display: none !important;
}
/* On hover over a viewed item: swap to eye-closed */
.file-sidebar-file-item.viewed:hover .file-sidebar-eye-open {
display: none !important;
}
.file-sidebar-file-item.viewed:hover .file-sidebar-eye-closed {
display: block !important;
}
/* ---- Date group headers ---- */
.file-sidebar-date-group-header {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
padding: 0 10px 2px 10px;
margin-top: 8px;
user-select: none;
}
.file-sidebar-date-group-header:first-child {
margin-top: 0;
}
/* ---- Loading state ---- */
.file-sidebar-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 0;
opacity: 0.5;
}
/* ---- Empty state ---- */
.file-sidebar-empty {
padding: 12px 16px 24px 16px;
text-align: center;
}
.file-sidebar-empty-text {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 2px 0;
}
.file-sidebar-empty-hint {
font-size: 11px;
color: var(--text-muted);
opacity: 0.7;
margin: 0;
}
/* ---- Thumbnail tooltip (hover preview) ---- */
.file-sidebar-thumb-tooltip {
position: fixed;
z-index: 9999;
transform: translateY(-50%);
background: var(--bg-surface, #fff);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 4px;
width: 150px;
pointer-events: none;
animation: file-sidebar-thumb-in 0.12s ease;
}
.file-sidebar-thumb-img {
width: 100%;
height: auto;
display: block;
border-radius: 4px;
}
@keyframes file-sidebar-thumb-in {
from {
opacity: 0;
transform: translateY(-50%) scale(0.94);
}
to {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
@@ -0,0 +1,297 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useFileManagement } from "@app/contexts/FileContext";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
import "@app/components/shared/FileSidebarFileItem.css";
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
/** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */
function useLazyThumbnail(
fileId: FileId,
size: number,
thumbnailUrl?: string,
): string | undefined {
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
const attempted = useRef(false);
const indexedDB = useIndexedDB();
const { updateStirlingFileStub } = useFileManagement();
// Sync prop changes (e.g. thumbnail arrives after TTL bump)
useEffect(() => {
if (thumbnailUrl) setThumb(thumbnailUrl);
}, [thumbnailUrl]);
useEffect(() => {
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
return;
attempted.current = true;
let cancelled = false;
(async () => {
try {
const file = await indexedDB.loadFile(fileId);
if (!file || cancelled) return;
const thumbnail = await generateThumbnailForFile(file);
if (cancelled || !thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
} catch {
// non-critical
}
})();
return () => {
cancelled = true;
};
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
return thumb;
}
export function getFileExtension(name: string): string {
const parts = name.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
}
export type DateGroup =
| "today"
| "yesterday"
| "thisWeek"
| "thisMonth"
| "older";
export const DATE_GROUP_ORDER: DateGroup[] = [
"today",
"yesterday",
"thisWeek",
"thisMonth",
"older",
];
export function getDateGroup(lastModified: number | undefined): DateGroup {
if (!lastModified) return "older";
const now = new Date();
const today = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const d = new Date(lastModified);
const fileDay = new Date(
d.getFullYear(),
d.getMonth(),
d.getDate(),
).getTime();
const daysAgo = (today - fileDay) / 86400000;
if (daysAgo < 1) return "today";
if (daysAgo < 2) return "yesterday";
if (daysAgo < 7) return "thisWeek";
if (daysAgo < 30) return "thisMonth";
return "older";
}
export function formatFileDate(lastModifiedTs: number): string {
const lastModified = lastModifiedTs ? new Date(lastModifiedTs) : new Date();
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const fileDay = new Date(
lastModified.getFullYear(),
lastModified.getMonth(),
lastModified.getDate(),
);
if (fileDay.getTime() === today.getTime()) {
return lastModified
.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.toLowerCase();
}
if (fileDay.getTime() === yesterday.getTime()) {
return "Yesterday";
}
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
if (fileDay >= weekAgo) {
return lastModified.toLocaleDateString("en-US", { weekday: "long" });
}
return lastModified.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function CheckIcon({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<svg
className={className}
style={style}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function getSidebarFileIcon(ext: string): React.ReactElement {
const cls = "file-sidebar-file-icon file-sidebar-file-icon-hover-hide";
return <FileDocIcon className={cls} variant={getFileDocVariant(ext)} />;
}
export interface FileItemProps {
fileId: FileId;
name: string;
size?: number;
lastModified?: number;
isSelected: boolean;
isActive: boolean;
isViewedInViewer: boolean;
thumbnailUrl?: string;
onClick: (fileId: FileId) => void;
onEyeClick: (fileId: FileId, e: React.MouseEvent) => void;
}
export function FileItem({
fileId,
name,
size,
lastModified,
isSelected,
isActive,
isViewedInViewer,
thumbnailUrl,
onClick,
onEyeClick,
}: FileItemProps) {
const ext = getFileExtension(name);
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File";
// Only use raster thumbnails for PDFs and images — everything else uses scalable SVG icons
const useRasterThumb = ext === "pdf" || IMAGE_EXTENSIONS.has(ext);
const resolvedThumbnail = useLazyThumbnail(
fileId,
size ?? 0,
useRasterThumb ? thumbnailUrl : undefined,
);
const itemRef = useRef<HTMLDivElement>(null);
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
const handleMouseEnter = useCallback(() => {
setHoverRect(itemRef.current?.getBoundingClientRect() ?? null);
}, []);
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
const thumbPos =
hoverRect && resolvedThumbnail
? {
top: hoverRect.top + hoverRect.height / 2,
left: hoverRect.right + 10,
}
: null;
return (
<>
<div
ref={itemRef}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
onClick={() => onClick(fileId)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick(fileId)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="file-sidebar-file-icon-wrapper">
{isSelected ? (
<div className="file-sidebar-file-check">
<CheckIcon className="file-sidebar-check-svg" />
</div>
) : (
<>
<div className="file-sidebar-file-checkbox-hover" />
{getSidebarFileIcon(ext)}
</>
)}
</div>
<div className="file-sidebar-file-info">
<span
className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}
>
{name}
</span>
<span className="file-sidebar-file-meta">
{dateLabel}
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
</span>
</div>
<button
className="file-sidebar-eye-btn"
onClick={(e) => {
e.stopPropagation();
onEyeClick(fileId, e);
}}
tabIndex={-1}
type="button"
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"}
>
<VisibilityOutlinedIcon
className="file-sidebar-eye-open"
sx={{ fontSize: "1.1rem" }}
/>
<VisibilityOffOutlinedIcon
className="file-sidebar-eye-closed"
sx={{ fontSize: "1.1rem" }}
/>
</button>
</div>
{useRasterThumb &&
thumbPos &&
resolvedThumbnail &&
createPortal(
<div
className="file-sidebar-thumb-tooltip"
style={{ top: thumbPos.top, left: thumbPos.left }}
>
<img
src={resolvedThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
</>
);
}
@@ -18,6 +18,7 @@ interface HoverActionMenuProps {
actions: HoverAction[];
position?: "inside" | "outside";
className?: string;
visibility?: "state" | "cssHover";
}
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
@@ -25,6 +26,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
actions,
position = "inside",
className = "",
visibility = "state",
}) => {
const visibleActions = actions.filter((action) => !action.hidden);
@@ -32,10 +34,23 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
return null;
}
const style: React.CSSProperties = { zIndex: Z_INDEX_HOVER_ACTION_MENU };
if (visibility === "state") {
style.opacity = show ? 1 : 0;
style.pointerEvents = show ? "auto" : "none";
} else if (show) {
// Force visible (e.g. mobile) even when CSS-hover mode is active.
style.opacity = 1;
style.pointerEvents = "auto";
}
return (
<div
className={`${styles.hoverMenu} ${position === "outside" ? styles.outside : styles.inside} ${className}`}
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
style={style}
data-hover-action-menu="true"
data-hover-action-menu-mode={visibility}
data-force-visible={show ? "true" : "false"}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@@ -50,6 +50,7 @@ interface InfoBannerProps {
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
minHeight?: number | string;
closeIconColor?: string;
compact?: boolean;
}
/**
@@ -75,6 +76,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
buttonVariant = "light",
minHeight = 56,
closeIconColor,
compact = false,
}) => {
if (!show) {
return null;
@@ -85,9 +87,13 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
onDismiss?.();
};
const iconSize = compact ? "1rem" : "1.2rem";
const textSize = compact ? "xs" : "sm";
const buttonSize = compact ? "xs" : "xs";
return (
<Paper
p="sm"
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
@@ -105,22 +111,22 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
style={{ width: "100%" }}
>
<Group
gap="sm"
gap={compact ? "xs" : "sm"}
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
<LocalIcon
icon={icon}
width="1.2rem"
height="1.2rem"
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size="sm"
size={textSize}
style={{ color: textColor ?? toneStyle.text }}
>
{title}
@@ -128,9 +134,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
)}
<Text
fw={title ? 400 : 500}
size="sm"
size={textSize}
style={{ color: textColor ?? toneStyle.text }}
lineClamp={2}
lineClamp={compact ? 1 : 2}
>
{message}
</Text>
@@ -141,11 +147,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
<Button
variant={buttonVariant}
color={buttonColor ?? toneStyle.buttonColor}
size="xs"
size={buttonSize}
onClick={onButtonClick}
loading={loading}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
<LocalIcon
icon={buttonIcon}
width={compact ? "0.75rem" : "0.9rem"}
height={compact ? "0.75rem" : "0.9rem"}
/>
}
>
{buttonText}
@@ -160,7 +170,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
aria-label="Dismiss"
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
<LocalIcon
icon="close-rounded"
width={compact ? "0.85rem" : "1rem"}
height={compact ? "0.85rem" : "1rem"}
/>
</ActionIcon>
)}
</Group>
@@ -91,10 +91,10 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
: "transparent",
color: disabled
? "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))"
? "var(--text-muted)"
: isSelected
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))",
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-3))"
: "var(--text-primary)",
transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
cursor: disabled ? "not-allowed" : "pointer",
"&:hover": !disabled
@@ -275,6 +275,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
variant="subtle"
radius="md"
className="right-rail-icon"
data-testid="language-selector-button"
title={!opened && tooltip ? tooltip : undefined}
styles={{
root: {
@@ -292,14 +293,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
<Button
variant="subtle"
size="sm"
data-testid="language-selector-button"
leftSection={
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
}
styles={{
root: {
border: "none",
color:
"light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))",
color: "var(--text-primary)",
transition:
"background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
"&:hover": {
@@ -33,11 +33,11 @@ const MultiSelectControls = ({
>
<Group justify="space-between">
<Text size="sm">
{selectedCount} {t("fileManager.filesSelected", "files selected")}
{selectedCount} {t("fileManager.filesSelected", "files")}
</Text>
<Group>
<Button size="xs" variant="light" onClick={onClearSelection}>
{t("fileManager.clearSelection", "Clear Selection")}
{t("fileManager.clearSelection", "Clear files")}
</Button>
{onAddToUpload && (
@@ -37,7 +37,7 @@ const SECTION_ORDER: RightRailSection[] = ["top", "middle", "bottom"];
function renderWithTooltip(
node: React.ReactNode,
tooltip: React.ReactNode | undefined,
position: "left" | "right",
position: "left" | "right" | "bottom",
offset: number,
) {
if (!tooltip) return node;
@@ -36,6 +36,8 @@ export interface TextInputProps {
"aria-label"?: string;
/** Focus event handler */
onFocus?: () => void;
/** Allow the icon to receive pointer events (e.g. when icon is a clickable button) */
iconClickable?: boolean;
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
@@ -56,6 +58,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
readOnly = false,
"aria-label": ariaLabel,
onFocus,
iconClickable = false,
...props
},
ref,
@@ -76,7 +79,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
{icon && (
<span
className={styles.icon}
style={{ color: "var(--search-text-and-icon-color)" }}
style={{
pointerEvents: iconClickable ? "auto" : "none",
left: "12px",
}}
>
{icon}
</span>
@@ -96,8 +102,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
aria-label={ariaLabel}
onFocus={onFocus}
style={{
backgroundColor: "var(--input-bg)",
color: "var(--search-text-and-icon-color)",
paddingRight: shouldShowClearButton ? "40px" : "12px",
paddingLeft: icon ? "40px" : "12px",
}}
@@ -108,7 +112,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
type="button"
className={styles.clearButton}
onClick={handleClear}
style={{ color: "var(--search-text-and-icon-color)" }}
aria-label="Clear input"
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
@@ -0,0 +1,81 @@
import React, { useState, useEffect } from "react";
import { ActionIcon, Slider } from "@mantine/core";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
/**
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
*/
export function ViewerInlineControls() {
const { workbench } = useNavigationState();
const viewer = useViewer();
const [zoomPercent, setZoomPercent] = useState(100);
useEffect(() => {
const zoomState = viewer.getZoomState();
setZoomPercent(zoomState.zoomPercent || 100);
const unregister = viewer.registerImmediateZoomUpdate((pct) => {
setZoomPercent(pct);
});
return () => unregister?.();
}, [viewer.registerImmediateZoomUpdate]);
if (workbench !== "viewer") return null;
const sliderValue = Math.min(Math.max(zoomPercent, 20), 500);
return (
<div className="viewer-inline-controls">
{/* Divider */}
<div className="workbench-bar-divider" />
{/* Zoom controls */}
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomOut()}
aria-label="Zoom out"
>
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<div className="viewer-inline-controls__slider-wrap">
<Slider
value={sliderValue}
min={20}
max={500}
step={5}
onChange={(val) => {
viewer.zoomActions.setZoomLevel?.(val / 100);
}}
size="xs"
styles={{
root: { width: "6rem" },
thumb: { width: 14, height: 14 },
track: { height: 3 },
}}
label={null}
/>
</div>
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomIn()}
aria-label="Zoom in"
>
<ZoomInIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<span className="viewer-inline-controls__zoom-pct">
{Math.round(zoomPercent)}%
</span>
</div>
);
}
@@ -0,0 +1,188 @@
/* ========== WORKBENCH BAR ========== */
/* Horizontal toolbar at the top of the workbench area.
Replaces TopControls (view switcher) and RightRail (action buttons). */
.workbench-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
align-content: flex-start;
min-height: 48px;
padding: 0 8px;
background-color: var(--bg-toolbar);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
z-index: 50;
}
/* ---- View switcher (left) ---- */
.workbench-bar-views {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
order: 1;
height: 48px;
z-index: 0;
}
.workbench-bar-view-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
white-space: nowrap;
}
.workbench-bar-view-btn:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.workbench-bar-view-btn.active {
background-color: var(--mantine-color-blue-light, rgba(59, 130, 246, 0.12));
color: var(--mantine-color-blue-6, #3b82f6);
}
.workbench-bar-view-btn svg {
font-size: 16px !important;
width: 16px;
height: 16px;
}
.workbench-bar-view-label {
line-height: 1;
}
/* ---- Center: tool buttons ---- */
/* Single-row: center sits between views and globals */
.workbench-bar-center {
order: 2;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 48px;
overflow: hidden;
}
/* Two-row: center drops below views+globals */
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
order: 3;
flex: 0 0 100%;
height: auto;
overflow: visible;
padding: 4px 0;
border-top: 1px solid var(--border-subtle);
}
/* ---- Right: global buttons (theme / language / download) ---- */
.workbench-bar-globals {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
order: 3;
height: 48px;
margin-left: auto;
}
/* In two-row mode globals moves to row 1 right side */
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
order: 2;
margin-left: auto;
}
/* Shared action icon style — applies to both center and global buttons */
.workbench-bar-action-icon {
color: var(--text-secondary) !important;
width: 28px !important;
height: 28px !important;
background: transparent !important;
}
.workbench-bar-action-icon svg,
.workbench-bar-action-icon img {
color: inherit !important;
fill: currentColor;
}
.workbench-bar-action-icon:hover {
color: var(--text-primary) !important;
background-color: var(--bg-hover) !important;
}
.workbench-bar-action-icon[data-variant="filled"] {
background: var(--mantine-color-blue-6, #3b82f6) !important;
color: #fff !important;
}
.workbench-bar-action-icon[data-variant="filled"]:hover {
background: var(--mantine-color-blue-7, #2563eb) !important;
color: #fff !important;
}
.workbench-bar-action-wrapper {
display: flex;
align-items: center;
}
.workbench-bar-tooltip-wrapper {
display: inline-flex;
align-items: center;
}
.workbench-bar-divider {
width: 1px;
height: 16px;
background-color: var(--border-subtle);
margin: 0 4px;
}
/* Extra breathing room around the export/close separator */
.workbench-bar-globals-sep {
margin: 0 8px;
}
/* ---- Inline viewer controls (page nav + zoom) ---- */
.viewer-inline-controls {
display: flex;
align-items: center;
gap: 2px;
}
.viewer-inline-controls__page {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
min-width: 3rem;
text-align: center;
white-space: nowrap;
}
.viewer-inline-controls__slider-wrap {
display: flex;
align-items: center;
padding: 0 4px;
}
.viewer-inline-controls__zoom-pct {
font-size: 12px;
color: var(--text-muted);
min-width: 2.5rem;
text-align: right;
white-space: nowrap;
}
@@ -0,0 +1,490 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRightRail } from "@app/contexts/RightRailContext";
import {
useFileState,
useFileSelection,
useFileActions,
} from "@app/contexts/FileContext";
import { isStirlingFile } from "@app/types/fileContext";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import { downloadFile } from "@app/services/downloadService";
import {
RightRailButtonConfig,
RightRailRenderContext,
RightRailSection,
} from "@app/types/rightRail";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import FolderIcon from "@mui/icons-material/Folder";
import CloseIcon from "@mui/icons-material/Close";
import PrintIcon from "@mui/icons-material/Print";
import "@app/components/shared/WorkbenchBar.css";
const SECTION_ORDER: RightRailSection[] = ["top", "middle", "bottom"];
interface ViewOption {
value: WorkbenchType;
label: string;
icon: React.ReactNode;
}
interface WorkbenchBarProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
hasFiles: boolean;
}
function renderWithTooltip(
node: React.ReactNode,
tooltip: React.ReactNode | undefined,
) {
if (!tooltip) return node;
return (
<Tooltip
content={tooltip}
position="bottom"
offset={6}
arrow
portalTarget={typeof document !== "undefined" ? document.body : undefined}
>
<div className="workbench-bar-tooltip-wrapper">{node}</div>
</Tooltip>
);
}
export default function WorkbenchBar({
currentView,
setCurrentView,
hasFiles,
}: WorkbenchBarProps) {
const { t } = useTranslation();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const {
pageEditorFunctions,
toolPanelMode,
leftPanelView,
customWorkbenchViews,
} = useToolWorkflow();
const { selectedTool } = useNavigationState();
const isCustomView = !isBaseWorkbench(currentView);
const disableForFullscreen =
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const viewerContext = React.useContext(ViewerContext);
const { selectors } = useFileState();
const { selectedFiles, selectedFileIds } = useFileSelection();
const { actions: fileActions } = useFileActions();
const activeFiles = selectors.getFiles();
const { activeFileId, setActiveFileId } = useViewer();
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount =
pageEditorFunctions?.selectedPageIds?.length ?? 0;
const totalItems = useMemo(() => {
if (currentView === "pageEditor") return pageEditorTotalPages;
return activeFiles.length;
}, [currentView, pageEditorTotalPages, activeFiles.length]);
const selectedCount = useMemo(() => {
if (currentView === "pageEditor") return pageEditorSelectedCount;
return selectedFileIds.length;
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
const sectionsWithButtons = useMemo(() => {
return SECTION_ORDER.map((section) => {
const sectionButtons = buttons.filter(
(btn) => (btn.section ?? "top") === section && (btn.visible ?? true),
);
return { section, buttons: sectionButtons };
}).filter((entry) => entry.buttons.length > 0);
}, [buttons]);
const handleExportAll = useCallback(
async (forceNewFile = false) => {
if (currentView === "viewer") {
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
if (!buffer) return;
const fileToExport =
selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
if (!fileToExport) return;
const stub = isStirlingFile(fileToExport)
? selectors.getStirlingFileStub(fileToExport.fileId)
: undefined;
try {
const result = await downloadFile({
data: new Blob([buffer], { type: "application/pdf" }),
filename: fileToExport.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
});
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false,
});
}
} catch (error) {
console.error("[WorkbenchBar] Failed to export viewer file:", error);
}
return;
}
if (currentView === "pageEditor") {
pageEditorFunctions?.onExportAll?.();
return;
}
const filesToExport =
selectedFiles.length > 0 ? selectedFiles : activeFiles;
for (const file of filesToExport) {
const stub = isStirlingFile(file)
? selectors.getStirlingFileStub(file.fileId)
: undefined;
try {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
});
if (result.cancelled) continue;
if (!forceNewFile && stub && result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false,
});
}
} catch (error) {
console.error(
"[WorkbenchBar] Failed to export file:",
file.name,
error,
);
}
}
},
[
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
selectors,
fileActions,
],
);
const handlePrint = useCallback(() => {
viewerContext?.printActions?.print?.();
}, [viewerContext]);
const handleClose = useCallback(async () => {
if (currentView === "fileEditor") {
await fileActions.clearAllFiles();
} else if (currentView === "viewer") {
const file =
(activeFileId
? activeFiles.find(
(f) => isStirlingFile(f) && f.fileId === activeFileId,
)
: null) ?? activeFiles[0];
const countBeforeRemove = activeFiles.length;
if (file && isStirlingFile(file)) {
// Pick the next file to show before removing, so the sidebar stays in sync.
const remaining = activeFiles.filter(
(f) => isStirlingFile(f) && f.fileId !== file.fileId,
);
const nextFile = remaining.find(isStirlingFile) ?? null;
await fileActions.removeFiles([file.fileId], false);
if (countBeforeRemove <= 1) {
setCurrentView("fileEditor");
} else if (nextFile) {
setActiveFileId(nextFile.fileId);
}
} else if (countBeforeRemove <= 1) {
setCurrentView("fileEditor");
}
} else if (currentView === "pageEditor") {
pageEditorFunctions?.closePdf?.();
}
}, [
currentView,
fileActions,
activeFiles,
activeFileId,
setActiveFileId,
pageEditorFunctions,
setCurrentView,
]);
const downloadTooltip = useMemo(() => {
if (currentView === "pageEditor")
return t("rightRail.exportAll", "Export PDF");
if (currentView === "viewer") return terminology.download;
if (selectedCount > 0) return terminology.downloadSelected;
return terminology.downloadAll;
}, [currentView, selectedCount, t, terminology]);
const renderButton = useCallback(
(btn: RightRailButtonConfig) => {
const action = actions[btn.id];
const disabled = Boolean(
btn.disabled || allButtonsDisabled || disableForFullscreen,
);
const isActive = Boolean(btn.active);
const triggerAction = () => {
if (!disabled) action?.();
};
if (btn.render) {
const context: RightRailRenderContext = {
id: btn.id,
disabled,
allButtonsDisabled,
action,
triggerAction,
active: isActive,
};
return btn.render(context) ?? null;
}
if (!btn.icon) return null;
const ariaLabel =
btn.ariaLabel ||
(typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined);
const buttonNode = (
<ActionIcon
variant={isActive ? "filled" : "subtle"}
color={isActive ? "blue" : undefined}
radius="md"
className="workbench-bar-action-icon"
onClick={triggerAction}
disabled={disabled}
aria-label={ariaLabel}
aria-pressed={isActive ? true : undefined}
>
{btn.icon}
</ActionIcon>
);
return renderWithTooltip(buttonNode, btn.tooltip);
},
[actions, allButtonsDisabled, disableForFullscreen],
);
// View options
const viewOptions: ViewOption[] = [
{
value: "viewer",
label: t("workbenchBar.viewer", "Viewer"),
icon: <InsertDriveFileIcon fontSize="small" />,
},
{
value: "fileEditor",
label: t("workbenchBar.activeFiles", "Active Files"),
icon: <FolderIcon fontSize="small" />,
},
...(selectedTool === "multiTool"
? [
{
value: "pageEditor" as WorkbenchType,
label: t("workbenchBar.multiTool", "Multi-Tool"),
icon: (
<LocalIcon
icon="dashboard-customize-outline-rounded"
width="1rem"
height="1rem"
/>
),
},
]
: []),
...customWorkbenchViews
.filter((v) => v.data != null)
.map((v) => ({
value: v.workbenchId,
label: v.label,
icon: v.icon ?? <InsertDriveFileIcon fontSize="small" />,
})),
];
const barRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const bar = barRef.current;
if (!bar) return;
const measure = () => {
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
const globalsEl = bar.querySelector<HTMLElement>(
".workbench-bar-globals",
);
const centerEl = bar.querySelector<HTMLElement>(".workbench-bar-center");
const viewsWidth = viewsEl?.offsetWidth ?? 0;
const globalsWidth = globalsEl?.offsetWidth ?? 0;
const centerChildren = centerEl
? (Array.from(centerEl.children) as HTMLElement[])
: [];
const centerWidth =
centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) +
Math.max(0, centerChildren.length - 1) * 2; // gap: 2px
const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding
bar.dataset.wrapped = String(needed > bar.clientWidth);
};
const ro = new ResizeObserver(measure);
ro.observe(bar);
measure();
return () => ro.disconnect();
}, []);
return (
<div
ref={barRef}
className="workbench-bar"
data-wrapped="true"
data-tour="workbench-bar"
>
{/* Left: View switcher */}
<div className="workbench-bar-views">
{hasFiles &&
viewOptions.map((opt) => (
<button
key={opt.value}
className={`workbench-bar-view-btn${currentView === opt.value ? " active" : ""}`}
onClick={() => setCurrentView(opt.value)}
aria-pressed={currentView === opt.value}
type="button"
>
{opt.icon}
<span className="workbench-bar-view-label">{opt.label}</span>
</button>
))}
</div>
{/* Tool buttons — second row, only rendered when buttons exist */}
{sectionsWithButtons.length > 0 && (
<div className="workbench-bar-center">
{sectionsWithButtons.map(
({ section, buttons: sectionButtons }, idx) => (
<React.Fragment key={section}>
{idx > 0 && <div className="workbench-bar-divider" />}
{sectionButtons.map((btn) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div key={btn.id} className="workbench-bar-action-wrapper">
{content}
</div>
);
})}
</React.Fragment>
),
)}
</div>
)}
{/* Right: Global buttons — export group left, close anchored right */}
<div className="workbench-bar-globals">
{/* Print */}
{currentView === "viewer" &&
renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={handlePrint}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
aria-label={t("rightRail.print", "Print PDF")}
>
<PrintIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>,
t("rightRail.print", "Print PDF"),
)}
{/* Download */}
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={() => handleExportAll()}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
>
<LocalIcon
icon={icons.downloadIconName}
width="1rem"
height="1rem"
/>
</ActionIcon>,
downloadTooltip,
)}
{/* Save As */}
{icons.saveAsIconName &&
renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={() => handleExportAll(true)}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
>
<LocalIcon
icon={icons.saveAsIconName}
width="1rem"
height="1rem"
/>
</ActionIcon>,
t("rightRail.saveAs", "Save As"),
)}
{/* Separator: export group | close */}
{!isCustomView && (
<div className="workbench-bar-divider workbench-bar-globals-sep" />
)}
{/* Close (context-aware: close all / close viewer file / close page editor) */}
{!isCustomView &&
renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={handleClose}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
aria-label={
currentView === "fileEditor"
? t("rightRail.closeAll", "Close All")
: t("rightRail.closePdf", "Close PDF")
}
>
<CloseIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>,
currentView === "fileEditor"
? t("rightRail.closeAll", "Close All")
: t("rightRail.closePdf", "Close PDF"),
)}
</div>
</div>
);
}
@@ -19,6 +19,8 @@ import {
import { useTranslation } from "react-i18next";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import LanguageSelector from "@app/components/shared/LanguageSelector";
import type { ToolPanelMode } from "@app/constants/toolPanel";
import type {
StartupView,
@@ -47,6 +49,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
const { toggleTheme, themeMode } = useRainbowThemeContext();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
preferences.autoUnzipFileLimit,
);
@@ -355,6 +358,67 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Paper>
)}
{/* Appearance */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.theme", "Theme")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.themeDescription",
"Switch between light and dark mode",
)}
</Text>
</div>
<SegmentedControl
value={themeMode === "rainbow" ? "dark" : themeMode}
onChange={(val) => {
if ((themeMode === "dark") !== (val === "dark")) toggleTheme();
}}
data={[
{
label: t("settings.general.themeLight", "Light"),
value: "light",
},
{
label: t("settings.general.themeDark", "Dark"),
value: "dark",
},
]}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.language", "Language")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.languageDescription",
"Choose the display language",
)}
</Text>
</div>
<LanguageSelector position="bottom-end" offset={6} />
</div>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div
@@ -412,7 +476,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.defaultStartupViewDescription",
"Choose which tab is active in the left column when the app starts",
"Choose which view is active when the app starts",
)}
</Text>
</div>
@@ -1,12 +1,19 @@
import React from "react";
import { Box, Center, Image } from "@mantine/core";
import { Box, Center, Loader, Stack, Text } from "@mantine/core";
import LockIcon from "@mui/icons-material/Lock";
import { getFileTypeIcon } from "@app/components/shared/filePreview/getFileTypeIcon";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { detectFileExtension } from "@app/utils/fileUtils";
export interface DocumentThumbnailProps {
file: File | StirlingFileStub | null;
thumbnail?: string | null;
isEncrypted?: boolean;
isLoading?: boolean;
iconSize?: string | number;
imgClassName?: string;
onImageError?: React.ReactEventHandler<HTMLImageElement>;
style?: React.CSSProperties;
onClick?: () => void;
children?: React.ReactNode;
@@ -15,14 +22,19 @@ export interface DocumentThumbnailProps {
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
file,
thumbnail,
isEncrypted = false,
isLoading = false,
iconSize = "4rem",
imgClassName,
onImageError,
style = {},
onClick,
children,
}) => {
if (!file) return null;
const containerStyle = {
position: "relative" as const,
const containerStyle: React.CSSProperties = {
position: "relative",
cursor: onClick ? "pointer" : "default",
transition: "opacity 0.2s ease",
width: "100%",
@@ -33,20 +45,29 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
...style,
};
if (thumbnail) {
if (thumbnail && !isEncrypted) {
return (
<Box style={containerStyle} onClick={onClick}>
<PrivateContent>
<Image
<img
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
style={{
maxWidth: "100%",
maxHeight: "100%",
width: "auto",
height: "auto",
}}
className={imgClassName}
style={
imgClassName
? undefined
: {
maxWidth: "100%",
maxHeight: "100%",
width: "auto",
height: "auto",
objectFit: "contain",
}
}
draggable={false}
loading="lazy"
decoding="async"
onError={onImageError}
/>
</PrivateContent>
{children}
@@ -54,17 +75,93 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
);
}
if (isEncrypted) {
return (
<Box style={containerStyle} onClick={onClick}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "calc(100% - 8px)",
height: "calc(100% - 8px)",
gap: "0.5rem",
border: "2px dashed var(--mantine-color-red-5)",
borderRadius: "10px",
}}
>
<LockIcon
style={{ fontSize: iconSize, color: "var(--mantine-color-red-6)" }}
/>
<span
style={{
fontSize: "0.65rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--mantine-color-red-6)",
background: "rgba(220,38,38,0.1)",
padding: "2px 8px",
borderRadius: "6px",
}}
>
Locked
</span>
</div>
{children}
</Box>
);
}
if (isLoading) {
return (
<Box style={containerStyle} onClick={onClick}>
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
{children}
</Box>
);
}
const ext = detectFileExtension(file.name ?? "").toUpperCase();
return (
<Box style={containerStyle} onClick={onClick}>
<Center
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: "0.25rem",
flexDirection: "column",
gap: "0.75rem",
}}
>
<PrivateContent>{getFileTypeIcon(file)}</PrivateContent>
<PrivateContent>{getFileTypeIcon(file, iconSize)}</PrivateContent>
{ext && (
<span
style={{
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--text-secondary)",
background: "rgb(var(--border))",
padding: "3px 10px",
borderRadius: "6px",
}}
>
{ext}
</span>
)}
</Center>
{children}
</Box>
@@ -1,17 +1,84 @@
import React from "react";
import JavascriptIcon from "@mui/icons-material/Javascript";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import type { FileDocVariant } from "@app/components/shared/FileDocIcon";
import type { StirlingFileStub } from "@app/types/fileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
export const SPREADSHEET_EXTS = new Set(["csv", "xls", "xlsx", "ods"]);
export const DOC_EXTS = new Set([
"md",
"markdown",
"txt",
"doc",
"docx",
"odt",
"rtf",
]);
export const IMAGE_EXTS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
"tiff",
"tif",
]);
export const ARCHIVE_EXTS = new Set([
"zip",
"tar",
"gz",
"rar",
"7z",
"cbz",
"cbr",
]);
export const CODE_EXTS = new Set([
"js",
"ts",
"jsx",
"tsx",
"html",
"css",
"json",
"xml",
"yaml",
"yml",
]);
type FileLike = File | StirlingFileStub;
/**
* Returns an appropriate file type icon for the provided file.
* - Uses the real file type and extension to decide the icon.
* - No any-casts; accepts File or StirlingFileStub.
*/
export function getFileDocVariant(ext: string, mime = ""): FileDocVariant {
if (ext === "pdf" || mime === "application/pdf") return "pdf";
if (
SPREADSHEET_EXTS.has(ext) ||
mime.includes("spreadsheet") ||
mime === "text/csv"
)
return "spreadsheet";
if (
DOC_EXTS.has(ext) ||
mime === "text/markdown" ||
mime === "text/plain" ||
mime.includes("word") ||
mime.includes("opendocument.text")
)
return "doc";
if (IMAGE_EXTS.has(ext) || mime.startsWith("image/")) return "image";
if (ARCHIVE_EXTS.has(ext) || mime.includes("zip") || mime.includes("archive"))
return "archive";
if (
CODE_EXTS.has(ext) ||
mime.includes("javascript") ||
mime.includes("json") ||
mime.includes("xml") ||
mime === "text/html"
)
return "code";
return "generic";
}
export function getFileTypeIcon(
file: FileLike,
size: number | string = "2rem",
@@ -19,29 +86,10 @@ export function getFileTypeIcon(
const name = (file?.name ?? "").toLowerCase();
const mime = (file?.type ?? "").toLowerCase();
const ext = detectFileExtension(name);
// JavaScript
if (ext === "js" || mime.includes("javascript")) {
return (
<JavascriptIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
/>
);
}
// PDF
if (ext === "pdf" || mime === "application/pdf") {
return (
<PictureAsPdfIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
/>
);
}
// Fallback generic
return (
<InsertDriveFileIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
<FileDocIcon
variant={getFileDocVariant(ext, mime)}
style={{ width: size, height: "auto" }}
/>
);
}
@@ -203,14 +203,14 @@ export default function ViewerAnnotationControls({
variant={isRedactMode ? "filled" : "subtle"}
color={isRedactMode ? "blue" : undefined}
radius="md"
className="right-rail-icon"
className="workbench-bar-action-icon"
onClick={handleRedactionToggle}
disabled={disabled || currentView !== "viewer"}
>
<LocalIcon
icon="scan-delete-rounded"
width="1.5rem"
height="1.5rem"
width="1.25rem"
height="1.25rem"
/>
</ActionIcon>
</Tooltip>
@@ -230,7 +230,7 @@ export default function ViewerAnnotationControls({
variant={annotationsHidden ? "filled" : "subtle"}
color={annotationsHidden ? "blue" : undefined}
radius="md"
className="right-rail-icon"
className="workbench-bar-action-icon"
onClick={handleToggleAnnotationsVisibility}
disabled={
disabled ||
@@ -247,8 +247,8 @@ export default function ViewerAnnotationControls({
? "visibility"
: "preview-off-rounded"
}
width="1.5rem"
height="1.5rem"
width="1.25rem"
height="1.25rem"
/>
</ActionIcon>
</Tooltip>
@@ -43,11 +43,11 @@ const CreateSessionPanel = ({
<>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">
{t("quickAccess.selectedFile", "Selected file")}
{t("quickAccess.selectedFile", "File")}
</div>
<div className="quick-access-popout__row-title">
{selectedFiles[0]?.name ||
t("quickAccess.noFile", "No file selected")}
t("quickAccess.noFile", "No file loaded")}
</div>
</div>
@@ -15,6 +15,7 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--search-text-and-icon-color);
}
.input {
@@ -26,6 +27,8 @@
outline: none;
box-shadow: none;
transition: box-shadow 0.2s ease;
background-color: var(--input-bg);
color: var(--search-text-and-icon-color);
}
.input::placeholder {
@@ -62,6 +65,7 @@
justify-content: center;
font-size: 16px;
transition: background-color 0.2s ease;
color: var(--search-text-and-icon-color);
}
.clearButton:hover {
@@ -1,6 +1,5 @@
import { useState, useRef } from "react";
import { ActionIcon, ScrollArea, Switch } from "@mantine/core";
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
import { useRef } from "react";
import { ScrollArea, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import FullscreenToolList from "@app/components/tools/FullscreenToolList";
@@ -9,10 +8,8 @@ import { ToolId } from "@app/types/toolId";
import { useFocusTrap } from "@app/hooks/useFocusTrap";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import { Tooltip } from "@app/components/shared/Tooltip";
import "@app/components/tools/ToolPanel.css";
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface FullscreenToolSurfaceProps {
searchQuery: string;
@@ -28,7 +25,6 @@ interface FullscreenToolSurfaceProps {
onSelect: (id: ToolId) => void;
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
toggleLabel: string;
geometry: ToolPanelGeometry | null;
}
@@ -42,47 +38,17 @@ const FullscreenToolSurface = ({
onSearchChange,
onSelect,
onToggleDescriptions,
onExitFullscreenMode,
toggleLabel,
onExitFullscreenMode: _onExitFullscreenMode,
geometry,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const [isExiting, setIsExiting] = useState(false);
const surfaceRef = useRef<HTMLDivElement>(null);
const isRTL =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, !isExiting);
useFocusTrap(surfaceRef, true);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const handleExit = () => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReducedMotion) {
onExitFullscreenMode();
return;
}
setIsExiting(true);
const el = surfaceRef.current;
if (!el) {
onExitFullscreenMode();
return;
}
// Rely on CSS animation end rather than duplicating timing in JS
el.addEventListener(
"animationend",
() => {
onExitFullscreenMode();
},
{ once: true },
);
};
const style = geometry
? {
left: `${geometry.left}px`,
@@ -103,10 +69,7 @@ const FullscreenToolSurface = ({
)}
data-tour="tool-panel"
>
<div
ref={surfaceRef}
className={`tool-panel__fullscreen-surface-inner ${isExiting ? "tool-panel__fullscreen-surface-inner--exiting" : ""}`}
>
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
<header className="tool-panel__fullscreen-header">
<div className="tool-panel__fullscreen-brand">
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
@@ -115,29 +78,6 @@ const FullscreenToolSurface = ({
className="tool-panel__fullscreen-brand-text"
/>
</div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
openOnFocus={false}
containerStyle={{ zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE }}
>
<ActionIcon
variant="subtle"
radius="xl"
size="md"
onClick={handleExit}
aria-label={toggleLabel}
style={{ color: "var(--right-rail-icon)" }}
>
<DoubleArrowIcon
fontSize="small"
style={{ transform: isRTL ? undefined : "rotate(180deg)" }}
/>
</ActionIcon>
</Tooltip>
</div>
</header>
<div className="tool-panel__fullscreen-controls">
@@ -1,3 +1,14 @@
/* ---- Viewer mode mini-toolbar (top of ToolPanel) ---- */
.tool-panel-viewer-bar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-subtle);
background-color: var(--bg-toolbar);
flex-shrink: 0;
}
/* CSS Custom Properties for Fullscreen Mode */
.tool-panel__fullscreen-surface-inner {
--fullscreen-bg-surface-1: color-mix(
@@ -109,16 +120,8 @@
var(--text-primary) 20%,
var(--border-subtle)
);
--fullscreen-text-icon: color-mix(
in srgb,
var(--text-primary) 90%,
var(--text-muted)
);
--fullscreen-text-icon-compact: color-mix(
in srgb,
var(--text-primary) 88%,
var(--text-muted)
);
--fullscreen-text-icon: var(--text-primary);
--fullscreen-text-icon-compact: var(--text-primary);
}
.tool-panel {
@@ -128,6 +131,49 @@
max-width 0.3s ease;
}
.tool-panel__collapsed-strip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 10px;
gap: 8px;
height: 100%;
width: 100%;
}
.tool-panel__expand-btn {
flex-shrink: 0;
color: var(--text-secondary) !important;
border-color: var(--border-subtle) !important;
}
.tool-panel__expand-btn svg {
color: var(--text-secondary) !important;
}
.tool-panel__expand-btn:hover {
color: var(--text-primary) !important;
border-color: var(--border) !important;
}
.tool-panel__collapsed-search-btn {
flex-shrink: 0;
color: var(--text-secondary) !important;
}
.tool-panel__collapsed-search-btn svg {
color: var(--text-secondary) !important;
}
.tool-panel__collapsed-search-btn:hover {
color: var(--text-primary) !important;
}
.tool-panel__back-btn {
flex-shrink: 0;
}
.tool-panel--fullscreen-active {
overflow: visible !important;
}
@@ -136,7 +182,9 @@
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
height: 48px;
padding: 0 0.5rem;
box-sizing: border-box;
}
.tool-panel__search-row .search-input-container {
@@ -469,6 +517,8 @@
.tool-panel__fullscreen-name {
color: var(--text-primary);
font-size: 13px !important;
font-weight: 500 !important;
}
.tool-panel__fullscreen-description {
@@ -604,7 +654,7 @@
@keyframes tool-panel-fullscreen-slide-in {
from {
transform: translateX(-6%) scaleX(0.85);
transform: translateX(6%) scaleX(0.85);
opacity: 0;
}
to {
@@ -619,14 +669,14 @@
opacity: 1;
}
to {
transform: translateX(-6%) scaleX(0.85);
transform: translateX(6%) scaleX(0.85);
opacity: 0;
}
}
@keyframes tool-panel-fullscreen-slide-in-rtl {
from {
transform: translateX(6%) scaleX(0.85);
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
}
to {
@@ -641,7 +691,7 @@
opacity: 1;
}
to {
transform: translateX(6%) scaleX(0.85);
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
}
}
+107 -46
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
@@ -8,15 +8,18 @@ import ToolRenderer from "@app/components/tools/ToolRenderer";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { ActionIcon, ScrollArea } from "@mantine/core";
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
import { ToolId } from "@app/types/toolId";
import { useIsMobile } from "@app/hooks/useIsMobile";
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
import { useTranslation } from "react-i18next";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import { useRightRail } from "@app/contexts/RightRailContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SearchIcon from "@mui/icons-material/Search";
import "@app/components/tools/ToolPanel.css";
// No props needed - component uses context
@@ -37,10 +40,14 @@ export default function ToolPanel() {
setSearchQuery,
selectedToolKey,
handleToolSelect,
handleBackToTools,
setPreviewFile,
toolPanelMode,
setToolPanelMode,
setLeftPanelView,
setReaderMode,
setSidebarsVisible,
sidebarsVisible,
readerMode,
} = useToolWorkflow();
@@ -54,8 +61,6 @@ export default function ToolPanel() {
leftPanelView === "toolPicker" &&
!isMobile &&
toolPickerVisible;
const isRTL =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
// Disable right rail buttons when fullscreen mode is active
useEffect(() => {
@@ -69,26 +74,42 @@ export default function ToolPanel() {
rightRailRef,
});
const toggleLabel = isFullscreenMode
? t("toolPanel.toggle.sidebar", "Switch to sidebar mode")
: t("toolPanel.toggle.fullscreen", "Switch to fullscreen mode");
const handleModeToggle = () => {
const nextMode = isFullscreenMode ? "sidebar" : "fullscreen";
setToolPanelMode(nextMode);
if (nextMode === "fullscreen" && leftPanelView !== "toolPicker") {
setLeftPanelView("toolPicker");
}
const handleExpand = () => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
};
const handleCollapse = () => {
setLeftPanelView("hidden");
};
const [focusSearch, setFocusSearch] = useState(false);
const focusSearchOnNextOpen = useRef(false);
const handleExpandAndSearch = () => {
focusSearchOnNextOpen.current = true;
handleExpand();
};
// Once the panel becomes visible, consume the focus-search request
useEffect(() => {
if (isPanelVisible && focusSearchOnNextOpen.current) {
focusSearchOnNextOpen.current = false;
setFocusSearch(true);
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
const id = setTimeout(() => setFocusSearch(false), 100);
return () => clearTimeout(id);
}
}, [isPanelVisible]);
const computedWidth = () => {
if (isMobile) {
return "100%";
}
if (!isPanelVisible) {
return "0";
return "3.5rem";
}
return "18.5rem";
@@ -109,7 +130,7 @@ export default function ToolPanel() {
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
@@ -117,21 +138,52 @@ export default function ToolPanel() {
padding: "0",
}}
>
{!fullscreenExpanded && (
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<ActionIcon
variant="outline"
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
size="md"
className="tool-panel__collapsed-search-btn"
onClick={handleExpandAndSearch}
aria-label={t("toolPanel.search", "Search tools")}
style={{ marginTop: "8px" }}
>
<SearchIcon sx={{ fontSize: "1.25rem" }} />
</ActionIcon>
</div>
)}
{!fullscreenExpanded && isPanelVisible && (
<div
style={{
opacity: isMobile || isPanelVisible ? 1 : 0,
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
<div
className="tool-panel__search-row"
style={{
backgroundColor: "var(--tool-panel-search-bg)",
borderBottom: "1px solid var(--tool-panel-search-border-bottom)",
backgroundColor: "transparent",
borderBottom: "1px solid var(--border-subtle)",
}}
>
<ToolSearch
@@ -139,29 +191,19 @@ export default function ToolPanel() {
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={focusSearch}
/>
{!isMobile && leftPanelView === "toolPicker" && (
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
openOnFocus={false}
>
<ActionIcon
variant="subtle"
radius="xl"
style={{ color: "var(--right-rail-icon)" }}
onClick={handleModeToggle}
aria-label={toggleLabel}
className="tool-panel__mode-toggle"
>
<DoubleArrowIcon
fontSize="small"
style={{ transform: isRTL ? "scaleX(-1)" : undefined }}
/>
</ActionIcon>
</Tooltip>
)}
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
style={{ flexShrink: 0 }}
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
</div>
{searchQuery.trim().length > 0 ? (
@@ -185,6 +227,26 @@ export default function ToolPanel() {
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div
style={{
borderBottom: "1px solid var(--border-subtle)",
flexShrink: 0,
}}
>
<Button
variant="light"
color="blue"
size="sm"
fullWidth
radius={0}
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
onClick={handleBackToTools}
aria-label={t("toolPanel.backToTools", "Back to all tools")}
styles={{ root: { justifyContent: "flex-start" } }}
>
{t("toolPanel.backToTools", "Back to all tools")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
@@ -224,7 +286,6 @@ export default function ToolPanel() {
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
toggleLabel={toggleLabel}
geometry={fullscreenGeometry}
/>
)}
@@ -0,0 +1,99 @@
import React, { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { useRightRail } from "@app/contexts/RightRailContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import type {
RightRailButtonConfig,
RightRailRenderContext,
} from "@app/types/rightRail";
/**
* Mini toolbar rendered at the top of the ToolPanel when in viewer mode.
* Shows "tool-panel" section buttons — viewer mode tools like annotate, redact, form fill.
*/
export function ToolPanelViewerBar() {
const { workbench } = useNavigationState();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const toolPanelButtons = buttons
.filter((btn) => btn.section === "tool-panel" && (btn.visible ?? true))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const renderButton = useCallback(
(btn: RightRailButtonConfig) => {
const action = actions[btn.id];
const disabled = Boolean(btn.disabled || allButtonsDisabled);
const isActive = Boolean(btn.active);
const triggerAction = () => {
if (!disabled) action?.();
};
if (btn.render) {
const context: RightRailRenderContext = {
id: btn.id,
disabled,
allButtonsDisabled,
action,
triggerAction,
active: isActive,
};
return btn.render(context) ?? null;
}
if (!btn.icon) return null;
const ariaLabel =
btn.ariaLabel ||
(typeof btn.tooltip === "string" ? btn.tooltip : undefined);
const buttonNode = (
<ActionIcon
variant={isActive ? "filled" : "subtle"}
color={isActive ? "blue" : undefined}
radius="md"
className="workbench-bar-action-icon"
onClick={triggerAction}
disabled={disabled}
aria-label={ariaLabel}
aria-pressed={isActive || undefined}
>
{btn.icon}
</ActionIcon>
);
if (!btn.tooltip) return buttonNode;
return (
<Tooltip
content={btn.tooltip}
position="bottom"
offset={6}
arrow
portalTarget={document.body}
>
<div style={{ display: "inline-flex" }}>{buttonNode}</div>
</Tooltip>
);
},
[actions, allButtonsDisabled],
);
if (workbench !== "viewer" || toolPanelButtons.length === 0) return null;
return (
<div className="tool-panel-viewer-bar">
{toolPanelButtons.map((btn) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div
key={btn.id}
style={{ display: "inline-flex", alignItems: "center" }}
>
{content}
</div>
);
})}
</div>
);
}
@@ -65,12 +65,12 @@ const ToolPicker = ({
isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
const headerTextStyle: React.CSSProperties = {
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.5rem 0 0.25rem 0.5rem",
textTransform: "none",
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
opacity: 0.7,
fontSize: "0.68rem",
fontWeight: 600,
padding: "1rem 0 0.35rem 0.5rem",
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--text-muted)",
};
const toTitleCase = (s: string) =>
s.replace(
@@ -11,7 +11,7 @@ import {
} from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckIcon from "@mui/icons-material/Check";
import { useFileSelection } from "@app/contexts/FileContext";
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
import { EXECUTION_STATUS } from "@app/constants/automation";
@@ -29,7 +29,7 @@ export default function AutomationRun({
automateOperation,
}: AutomationRunProps) {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const selectedFiles = useViewScopedFiles();
const { regularTools } = useToolRegistry();
const toolRegistry = regularTools;
const cleanup = useResourceCleanup();
@@ -13,6 +13,9 @@ import {
} from "@app/components/tools/compare/compare";
import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { truncateCenter } from "@app/utils/textUtils";
const COMPARE_PANE_TITLE_MAX_LEN = 48;
// utilities moved to compare.ts
@@ -107,9 +110,14 @@ const CompareDocumentPane = ({
return (
<div className="compare-pane">
<div className="compare-header">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">
{title}
<Group justify="space-between" align="center" wrap="nowrap">
<Text
fw={600}
size="lg"
style={{ minWidth: 0, flex: 1 }}
title={title || undefined}
>
{title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title}
</Text>
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
@@ -23,7 +23,6 @@
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
padding-top: 3rem;
height: 100%;
min-height: 0;
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
@@ -522,6 +521,124 @@
}
}
/* Compare tool — file selection step (Original / Edited + swap column) */
.compare-step-selection {
width: 100%;
}
.compare-step-selection__clear-row {
display: flex;
justify-content: flex-end;
width: 100%;
}
/* Thumbnails + swap only — swap top aligns with first thumbnail, not “Original PDF” heading */
.compare-step-selection__thumbs-row {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.compare-step-selection__thumbs-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Full-height light bar to the right of both cards (reference UI) */
.compare-step-selection__swap {
flex-shrink: 0;
width: 36px;
min-width: 36px;
align-self: stretch;
margin: 0;
padding: 0.5rem 0.2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
border-radius: var(--radius-md);
border: 1px solid
color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default));
background: color-mix(
in srgb,
var(--mantine-color-blue-0) 92%,
var(--mantine-color-body)
);
color: var(--mantine-color-blue-filled);
cursor: pointer;
font: inherit;
transition:
background 0.12s ease,
border-color 0.12s ease;
}
.compare-step-selection__swap:hover:not(:disabled) {
background: color-mix(
in srgb,
var(--mantine-color-blue-1) 75%,
var(--mantine-color-body)
);
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 35%,
var(--border-default)
);
}
.compare-step-selection__swap:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.compare-step-selection__swap-icon {
font-size: 1.35rem !important;
color: var(--mantine-color-blue-filled);
}
.compare-step-selection__swap-label {
font-size: 0.7rem;
font-weight: 600;
line-height: 1.15;
text-align: center;
color: var(--mantine-color-blue-filled);
writing-mode: horizontal-tb;
}
[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap {
background: color-mix(
in srgb,
var(--mantine-color-blue-9) 22%,
var(--mantine-color-dark-6)
);
border-color: color-mix(
in srgb,
var(--mantine-color-blue-4) 40%,
var(--border-default)
);
color: var(--mantine-color-blue-3);
}
[data-mantine-color-scheme="”dark”"]
.compare-step-selection__swap:hover:not(:disabled) {
background: color-mix(
in srgb,
var(--mantine-color-blue-9) 32%,
var(--mantine-color-dark-6)
);
}
[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-icon,
[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-label {
color: var(--mantine-color-blue-3);
}
/* Pixel compare mode */
.compare-pixel-workbench {
padding: 16px 24px 32px;
@@ -12,7 +12,7 @@ import {
import { useTranslation } from "react-i18next";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import { useAllFiles } from "@app/contexts/FileContext";
import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector";
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
@@ -34,17 +34,17 @@ const CONTAINER_SIZE = 250; // Fit within actual pane width
const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
const { t } = useTranslation();
const { selectedFiles, selectedFileStubs } = useSelectedFiles();
const { files, fileStubs } = useAllFiles();
// Get the first selected file for preview
// Get the first file for preview
const selectedStub = useMemo(() => {
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
}, [selectedFileStubs]);
return fileStubs.length > 0 ? fileStubs[0] : null;
}, [fileStubs]);
// Get the first selected file for PDF processing
// Get the first file for PDF processing
const selectedFile = useMemo(() => {
return selectedFiles.length > 0 ? selectedFiles[0] : null;
}, [selectedFiles]);
return files.length > 0 ? files[0] : null;
}, [files]);
// Get thumbnail for the selected file
const [thumbnail, setThumbnail] = useState<string | null>(null);
@@ -76,9 +76,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({
) : null}
<span className="tool-panel__fullscreen-list-body">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
{tool.versionStatus === "alpha" && (
<Badge size="xs" variant="light" color="orange">
{t("toolPanel.alpha", "Alpha")}
@@ -78,9 +78,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({
) : null}
<span className="tool-panel__fullscreen-body">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
{tool.versionStatus === "alpha" && (
<Badge size="xs" variant="light" color="orange">
{/* we can add more translations for different badges in future, like beta, etc. */}
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import { useAllFiles } from "@app/contexts/FileContext";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
interface RotateSettingsProps {
@@ -17,12 +17,12 @@ const RotateSettings = ({
disabled = false,
}: RotateSettingsProps) => {
const { t } = useTranslation();
const { selectedFileStubs } = useSelectedFiles();
const { fileStubs } = useAllFiles();
// Get the first selected file for preview
// Get the first file for preview
const selectedStub = useMemo(() => {
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
}, [selectedFileStubs]);
return fileStubs.length > 0 ? fileStubs[0] : null;
}, [fileStubs]);
// Get thumbnail for the selected file
const [thumbnail, setThumbnail] = useState<string | null>(null);
@@ -59,17 +59,12 @@ const FileStatusIndicator = ({
const getPlaceholder = () => {
if (minFiles === undefined || minFiles === 1) {
return t(
"files.selectFromWorkbench",
"Select files from the workbench or ",
);
return t("files.selectFromWorkbench", "Add files to the workbench or ");
} else {
return t(
"files.selectMultipleFromWorkbench",
"Select at least {{count}} files from the workbench or ",
{
count: minFiles,
},
"Add at least {{count}} files to the workbench or ",
{ count: minFiles },
);
}
};
@@ -171,14 +166,12 @@ const FileStatusIndicator = ({
{" "}
{selectedFiles.length === 1 ? (
<PrivateContent>
{t("fileSelected", "Selected: {{filename}}", {
{t("fileSelected", "{{filename}}", {
filename: selectedFiles[0]?.name,
})}
</PrivateContent>
) : (
t("filesSelected", "{{count}} files selected", {
count: selectedFiles.length,
})
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
)}
</Text>
);
@@ -58,6 +58,10 @@ const OperationButton = ({
"tool.endpointUnavailable",
"This tool is unavailable on your server.",
),
filesLoading: t(
"tool.filesLoading",
"Files are still loading, please wait.",
),
noFiles: t("tool.noFiles", "Add a file to get started."),
invalidParams: t("tool.invalidParams", "Fill in the required settings."),
viewerMode: t(
@@ -120,6 +120,7 @@ function ReviewStepContent<TParams = unknown>({
{onUndo && (
<Tooltip
position="left"
content={t(
"undoOperationTooltip",
"Click to undo the last operation and restore the original files",
@@ -1,4 +1,4 @@
import { Text } from "@mantine/core";
import { Text, Box, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import OperationButton, {
OperationButtonProps,
@@ -18,8 +18,7 @@ export interface ScopedOperationButtonProps extends OperationButtonProps {
*
* - Viewer mode (multiple files loaded): appends "(this file)" to button text and
* shows a note naming the exact file that will be processed.
* - File-editor mode with N>1 selected files: appends "(N files)" to button text.
* - File-editor mode with 0 selected files: shows a hint to select files.
* - N>1 files loaded: appends "(N files)" to button text.
* - All other cases: no change to button text or layout.
*/
export function ScopedOperationButton({
@@ -30,7 +29,14 @@ export function ScopedOperationButton({
const { t } = useTranslation();
const { workbench } = useNavigationState();
const { activeFileIndex } = useViewer();
const { files: allFiles } = useAllFiles();
const { files: allFiles, fileIds } = useAllFiles();
// Disable until all files are hydrated — running early would silently skip unloaded files.
const isFilesHydrating = fileIds.length > allFiles.length;
const effectiveDisabledReason =
isFilesHydrating && props.disabledReason !== "endpointUnavailable"
? "filesLoading"
: props.disabledReason;
const isViewerMode = workbench === "viewer";
const isFileEditorMode = workbench === "fileEditor";
@@ -56,6 +62,11 @@ export function ScopedOperationButton({
? allFiles[activeFileIndex]?.name
: null;
const pendingCount = fileIds.length - allFiles.length;
const isBulkLoading = pendingCount > 1;
const loadProgress = isBulkLoading
? (allFiles.length / fileIds.length) * 100
: 0;
const showSelectFilesHint =
!disableScopeHints &&
isFileEditorMode &&
@@ -64,7 +75,54 @@ export function ScopedOperationButton({
return (
<>
<OperationButton {...props} submitText={scopedText} />
{isFilesHydrating ? (
<Box mx="md" mt="md">
<Button
fullWidth
disabled
loading={!isBulkLoading}
variant={props.variant ?? "filled"}
color={props.color ?? "blue"}
style={{
position: "relative",
overflow: "hidden",
minHeight: "2.5rem",
pointerEvents: "none",
}}
>
{isBulkLoading && (
<>
<Box
style={{
position: "absolute",
inset: 0,
width: `${loadProgress}%`,
backgroundColor: "rgba(255,255,255,0.15)",
transition: "width 0.3s ease",
borderRadius: "inherit",
}}
/>
<Text size="sm" style={{ position: "relative" }}>
{t(
"tool.filesLoadingProgress",
"{{loaded}} / {{total}} files loading...",
{
loaded: allFiles.length,
total: fileIds.length,
},
)}
</Text>
</>
)}
</Button>
</Box>
) : (
<OperationButton
{...props}
submitText={scopedText}
disabledReason={effectiveDisabledReason}
/>
)}
{viewerFileName && (
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
{t("tool.singleFileScope", "Only applying to: {{fileName}}", {
@@ -201,13 +201,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({
handleUnlessSpecialClick(e, () => handleClick(id));
};
const selectedStyles = isSelected
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
: {};
const buttonElement = navProps ? (
// For internal tools with URLs, render Button as an anchor for proper link behavior
<Button
component="a"
href={navProps.href}
onClick={navProps.onClick}
variant={isSelected ? "filled" : "subtle"}
variant="subtle"
size="sm"
radius="md"
fullWidth
@@ -219,6 +223,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -233,7 +238,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
target="_blank"
rel="noopener noreferrer"
onClick={handleExternalClick}
variant={isSelected ? "filled" : "subtle"}
variant="subtle"
size="sm"
radius="md"
fullWidth
@@ -245,6 +250,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -254,7 +260,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
) : (
// For unavailable tools, use regular button
<Button
variant={isSelected ? "filled" : "subtle"}
variant="subtle"
onClick={() => handleClick(id)}
size="sm"
radius="md"
@@ -269,6 +275,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
color: "var(--tools-text-and-icon-color)",
cursor: visuallyUnavailable ? "not-allowed" : undefined,
overflow: "visible",
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -292,7 +299,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
{star}
<Tooltip
content={tooltipContent}
position="right"
position="left"
arrow={true}
delay={500}
>
@@ -57,13 +57,16 @@
/* Compact tool buttons */
.tool-button {
font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */
padding-top: 0.375rem;
padding-bottom: 0.375rem;
font-size: 0.875rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.5rem;
}
.tool-button .mantine-Button-label {
font-size: 0.85rem;
font-size: 0.84rem;
font-weight: 500;
line-height: 1.2;
}
.tool-button-icon {
@@ -17,6 +17,7 @@ interface ToolSearchProps {
selectedToolKey?: string | null;
placeholder?: string;
hideIcon?: boolean;
iconOverride?: React.ReactNode;
onFocus?: () => void;
autoFocus?: boolean;
}
@@ -30,6 +31,7 @@ const ToolSearch = ({
selectedToolKey,
placeholder,
hideIcon = false,
iconOverride,
onFocus,
autoFocus = false,
}: ToolSearchProps) => {
@@ -96,10 +98,12 @@ const ToolSearch = ({
placeholder || t("toolPicker.searchPlaceholder", "Search tools...")
}
icon={
hideIcon ? undefined : (
<LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />
)
iconOverride ??
(hideIcon ? undefined : (
<LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />
))
}
iconClickable={!!iconOverride}
autoComplete="off"
onFocus={onFocus}
/>
@@ -5,6 +5,7 @@ import React, {
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import LockIcon from "@mui/icons-material/Lock";
@@ -138,8 +139,6 @@ export interface EmbedPdfViewerProps {
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File | null;
activeFileIndex?: number;
setActiveFileIndex?: (index: number) => void;
}
const EmbedPdfViewerContent = ({
@@ -147,9 +146,8 @@ const EmbedPdfViewerContent = ({
setSidebarsVisible: _setSidebarsVisible,
onClose,
previewFile,
activeFileIndex: externalActiveFileIndex,
setActiveFileIndex: externalSetActiveFileIndex,
}: EmbedPdfViewerProps) => {
const { t } = useTranslation();
const viewerRef = React.useRef<HTMLDivElement>(null);
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
@@ -178,6 +176,9 @@ const EmbedPdfViewerContent = ({
applyChanges: viewerApplyChanges,
pdfRenderMode,
cyclePdfRenderMode,
setActiveFileIndex: _setActiveFileIndex,
activeFileId,
setActiveFileId,
} = useViewer();
const scrollState = getScrollState();
@@ -327,63 +328,25 @@ const EmbedPdfViewerContent = ({
isInAnnotationTool && isPlacementMode && signatureConfig,
);
// Track which file tab is active
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
const setActiveFileIndex =
externalSetActiveFileIndex ?? setInternalActiveFileIndex;
// activeFileId (from ViewerContext) is the stable source of truth.
// We derive activeFileIndex from it so reorders after tool operations don't lose the viewed file.
const { activeFileId, setActiveFileId } = useViewer();
// Stable string key representing the current file list order.
// Using a joined ID string avoids depending on the activeFiles array reference,
// which is a new object every render and would cause an infinite effect loop.
const fileIdsKey = activeFiles.map((f) => f.fileId).join(",");
// When the file list actually changes, re-derive activeFileIndex from the stable activeFileId.
useEffect(() => {
if (!activeFileId || activeFiles.length === 0) return;
const newIndex = activeFiles.findIndex((f) => f.fileId === activeFileId);
if (newIndex !== -1 && newIndex !== activeFileIndex) {
setActiveFileIndex(newIndex);
}
}, [fileIdsKey, activeFileId]); // stable primitives — no infinite loop
// When the user manually switches file tabs, keep activeFileId in sync.
// Skips the initial mount to avoid overwriting an activeFileId set by handleViewFile.
const activeFileIndexMountedRef = useRef(false);
useEffect(() => {
if (!activeFileIndexMountedRef.current) {
activeFileIndexMountedRef.current = true;
return;
}
const fileId = activeFilesRef.current[activeFileIndex]?.fileId;
if (fileId && fileId !== activeFileId) {
setActiveFileId(fileId);
}
}, [activeFileIndex]);
// Reset active tab if it's out of bounds (safety net)
useEffect(() => {
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
setActiveFileIndex(0);
}
}, [activeFiles.length, activeFileIndex]);
// Determine which file to display
// Determine which file to display — use activeFileId (stable) not activeFileIndex (shifts on removal)
const currentFile = React.useMemo(() => {
if (previewFile) {
return previewFile;
} else if (activeFiles.length > 0) {
return activeFiles[activeFileIndex] || activeFiles[0];
const byId = activeFileId
? activeFiles.find(
(f) => isStirlingFile(f) && f.fileId === activeFileId,
)
: null;
return byId || activeFiles[0];
}
return null;
}, [previewFile, activeFiles, activeFileIndex]);
}, [previewFile, activeFiles, activeFileId]);
// Get file with URL for rendering
const fileWithUrl = useFileWithUrl(currentFile);
// Stable id — avoids blob URL churn when FileContext recreates file objects each render.
const currentFileStableId =
currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null;
const fileWithUrl = useFileWithUrl(currentFile, currentFileStableId);
// Determine the effective file to display
const effectiveFile = React.useMemo(() => {
@@ -703,7 +666,7 @@ const EmbedPdfViewerContent = ({
// Step 3: Create StirlingFiles and stubs for version history
// Only consume the current file, not all active files
const currentFileId = activeFiles[activeFileIndex]?.fileId;
const currentFileId = currentFileStableId;
if (!currentFileId) throw new Error("Current file ID not found");
const parentStub = selectors.getStirlingFileStub(currentFileId);
@@ -739,7 +702,6 @@ const EmbedPdfViewerContent = ({
}, [
currentFile,
activeFiles,
activeFileIndex,
exportActions,
actions,
selectors,
@@ -773,7 +735,7 @@ const EmbedPdfViewerContent = ({
});
// Get current file info for creating the updated version
const currentFileId = activeFiles[activeFileIndex]?.fileId;
const currentFileId = currentFileStableId;
if (!currentFileId) throw new Error("Current file ID not found");
const parentStub = selectors.getStirlingFileStub(currentFileId);
@@ -811,7 +773,6 @@ const EmbedPdfViewerContent = ({
[
currentFile,
activeFiles,
activeFileIndex,
actions,
selectors,
activeFileIds.length,
@@ -847,7 +808,7 @@ const EmbedPdfViewerContent = ({
type: "application/pdf",
});
const currentFileId = activeFiles[activeFileIndex]?.fileId;
const currentFileId = currentFileStableId;
if (!currentFileId) throw new Error("Current file ID not found");
const parentStub = selectors.getStirlingFileStub(currentFileId);
@@ -877,7 +838,6 @@ const EmbedPdfViewerContent = ({
[
currentFile,
activeFiles,
activeFileIndex,
actions,
selectors,
activeFileIds.length,
@@ -916,7 +876,7 @@ const EmbedPdfViewerContent = ({
const file = new File([blob], filename, { type: "application/pdf" });
// Create StirlingFiles and stubs for version history
const currentFileId = activeFiles[activeFileIndex]?.fileId;
const currentFileId = currentFileStableId;
if (!currentFileId) throw new Error("Current file ID not found");
const parentStub = selectors.getStirlingFileStub(currentFileId);
@@ -949,7 +909,6 @@ const EmbedPdfViewerContent = ({
redactionsApplied,
currentFile,
activeFiles,
activeFileIndex,
activeFileIds.length,
exportActions,
actions,
@@ -1157,7 +1116,11 @@ const EmbedPdfViewerContent = ({
// the effect re-fires before the async fetch completes.
}
if (currentFile && (fileChanged || providerChanged)) {
if (
currentFile &&
!isCurrentFileEncrypted &&
(fileChanged || providerChanged)
) {
console.log("[FormFill] Fetching form fields for:", currentFileId);
fetchFormFields(currentFile, currentFileId ?? undefined);
}
@@ -1219,7 +1182,12 @@ const EmbedPdfViewerContent = ({
<Center style={{ flex: 1 }}>
<Stack align="center" gap="md">
<LockIcon style={{ fontSize: 48, opacity: 0.5 }} />
<Text fw={500}>This PDF is password-protected</Text>
<Text fw={500}>
{t(
"encryptedPdfUnlock.viewerLocked",
"This PDF is password-protected",
)}
</Text>
<Button
variant="filled"
onClick={() => {
@@ -1228,7 +1196,7 @@ const EmbedPdfViewerContent = ({
}
}}
>
Unlock
{t("encryptedPdfUnlock.viewerUnlock", "Unlock")}
</Button>
</Stack>
</Center>
@@ -1329,7 +1297,7 @@ const EmbedPdfViewerContent = ({
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
activeFileIndex={activeFileIndex}
activeFileId={activeFileId}
/>
<BookmarkSidebar
visible={isBookmarkSidebarVisible}
@@ -162,7 +162,10 @@ export function LocalEmbedPDF({
});
}, [config?.enableLogin]);
// Convert File to URL if needed
// Stable key — avoids recreating the blob URL (and crashing ViewportPlugin) when
// FileContext produces new File object references for the same file content.
const fileStableKey =
fileId ?? (file ? `${(file as File).name}-${file.size}` : null);
useEffect(() => {
if (file) {
const objectUrl = URL.createObjectURL(file);
@@ -171,7 +174,17 @@ export function LocalEmbedPDF({
} else if (url) {
setPdfUrl(url);
}
}, [file, url]);
// When file is present, use the stable key to avoid blob URL churn from FileContext
// re-renders. When only url is provided, depend on url directly so changes are picked up.
}, [file ? fileStableKey : url]);
// Keyed by fileStableKey to avoid recomputing on every FileContext re-render.
const exportFileName = useMemo(() => {
if (fileName) return fileName;
if (file && "name" in file) return (file as File).name;
if (url) return url.split("/").pop()?.split("?")[0] || "document.pdf";
return "document.pdf";
}, [fileStableKey, fileName, url]);
// Create plugins configuration
const plugins = useMemo(() => {
@@ -183,17 +196,6 @@ export function LocalEmbedPDF({
);
const viewportGap = rootFontSize * 3.5;
// Determine export filename - use provided fileName, or extract from file/url
let exportFileName = "document.pdf";
if (fileName) {
exportFileName = fileName;
} else if (file && "name" in file) {
exportFileName = file.name;
} else if (url) {
const urlPath = url.split("/").pop() || "document.pdf";
exportFileName = urlPath.split("?")[0]; // Remove query params
}
return [
createPluginRegistration(DocumentManagerPluginPackage, {
initialDocuments: [
@@ -290,7 +292,7 @@ export function LocalEmbedPDF({
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl, enableAnnotations, fileName, file, url]);
}, [pdfUrl, enableAnnotations, exportFileName]);
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
@@ -4,6 +4,7 @@ import ArticleIcon from "@mui/icons-material/Article";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { useFileState } from "@app/contexts/FileContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import {
detectFileExtension,
@@ -12,7 +13,6 @@ import {
import { CONVERSION_MATRIX } from "@app/constants/convertConstants";
import { NonPdfBanner } from "@app/components/viewer/nonpdf/NonPdfBanner";
import { getFileTypeMeta } from "@app/components/viewer/nonpdf/types";
import { ImageViewer } from "@app/components/viewer/nonpdf/ImageViewer";
import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer";
import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer";
@@ -24,8 +24,6 @@ export interface ViewerProps {
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File | null;
activeFileIndex?: number;
setActiveFileIndex?: (index: number) => void;
}
export interface NonPdfViewerProps extends ViewerProps {
@@ -34,10 +32,6 @@ export interface NonPdfViewerProps extends ViewerProps {
export function NonPdfViewer({ file }: NonPdfViewerProps) {
const fileType = useMemo(() => detectNonPdfFileType(file), [file]);
const meta = useMemo(
() => getFileTypeMeta(fileType, file.name),
[fileType, file.name],
);
const { handleToolSelect, toolAvailability } = useToolWorkflow();
@@ -112,7 +106,6 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
}}
>
<NonPdfBanner
meta={meta}
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
/>
<Box
@@ -134,7 +127,7 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
export function NonPdfViewerWrapper(props: ViewerProps) {
const { selectors } = useFileState();
const activeFiles = selectors.getFiles();
const activeFileIndex = props.activeFileIndex ?? 0;
const { activeFileIndex } = useViewer();
const file =
props.previewFile ?? activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
@@ -143,7 +136,7 @@ export function NonPdfViewerWrapper(props: ViewerProps) {
return (
<Center style={{ flex: 1 }}>
<Text c="dimmed" size="sm">
No file selected
No file loaded
</Text>
</Center>
);
@@ -1,5 +1,12 @@
import { useState, useEffect } from "react";
import { Button, Paper, Group, NumberInput } from "@mantine/core";
import {
ActionIcon,
Button,
Paper,
Group,
NumberInput,
Slider,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext";
import { Tooltip } from "@app/components/shared/Tooltip";
@@ -12,6 +19,8 @@ import ViewWeekIcon from "@mui/icons-material/ViewWeek";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import WbSunnyIcon from "@mui/icons-material/WbSunny";
import WbTwilightIcon from "@mui/icons-material/WbTwilight";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
interface PdfViewerToolbarProps {
// Page navigation props (placeholders for now)
@@ -291,31 +300,48 @@ export function PdfViewerToolbar({
{/* Zoom Controls */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button
<ActionIcon
variant="subtle"
color="blue"
size="md"
radius="xl"
radius="md"
onClick={handleZoomOut}
style={{ minWidth: "2rem", padding: 0 }}
title={t("viewer.zoomOut", "Zoom out")}
aria-label={t("viewer.zoomOut", "Zoom out")}
>
<ZoomOutIcon fontSize="small" />
</ActionIcon>
<Slider
value={Math.min(Math.max(displayZoomPercent, 20), 500)}
min={20}
max={500}
step={5}
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
size="xs"
styles={{
root: { width: "6rem" },
thumb: { width: 14, height: 14 },
track: { height: 3 },
}}
label={null}
/>
<ActionIcon
variant="subtle"
color="blue"
radius="md"
onClick={handleZoomIn}
aria-label={t("viewer.zoomIn", "Zoom in")}
>
<ZoomInIcon fontSize="small" />
</ActionIcon>
<span
style={{
minWidth: "2.5rem",
textAlign: "center",
fontSize: 12,
color: "var(--text-muted)",
}}
>
</Button>
<span style={{ minWidth: "2.5rem", textAlign: "center" }}>
{displayZoomPercent}%
</span>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={handleZoomIn}
style={{ minWidth: "2rem", padding: 0 }}
title={t("viewer.zoomIn", "Zoom in")}
>
+
</Button>
</Group>
</Paper>
);
@@ -6,13 +6,13 @@ import { PrivateContent } from "@app/components/shared/PrivateContent";
interface ThumbnailSidebarProps {
visible: boolean;
onToggle: () => void;
activeFileIndex?: number;
activeFileId?: string | null;
}
export function ThumbnailSidebar({
visible,
onToggle: _onToggle,
activeFileIndex,
activeFileId,
}: ThumbnailSidebarProps) {
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
@@ -29,7 +29,7 @@ export function ThumbnailSidebar({
}
});
setThumbnails({});
}, [activeFileIndex]);
}, [activeFileId]);
// Keep a ref to thumbnails for cleanup on unmount
const thumbnailsRef = useRef(thumbnails);
@@ -5,6 +5,8 @@ import {
type ViewerProps,
} from "@app/components/viewer/NonPdfViewer";
import { useFileState } from "@app/contexts/FileContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { isStirlingFile } from "@app/types/fileContext";
import { isPdfFile } from "@app/utils/fileUtils";
export type { ViewerProps };
@@ -12,13 +14,16 @@ export type { ViewerProps };
const Viewer = (props: ViewerProps) => {
const { selectors } = useFileState();
const activeFiles = selectors.getFiles();
const activeFileIndex = props.activeFileIndex ?? 0;
const { activeFileId } = useViewer();
// Determine the active file — previewFile takes priority
// Determine the active file — previewFile takes priority, then look up by stable ID
const activeFile = useMemo(() => {
if (props.previewFile) return props.previewFile;
return activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
}, [props.previewFile, activeFiles, activeFileIndex]);
const byId = activeFileId
? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId)
: null;
return byId ?? activeFiles[0] ?? null;
}, [props.previewFile, activeFiles, activeFileId]);
// Route to the appropriate viewer based on file type
if (activeFile && !isPdfFile(activeFile)) {
@@ -1,59 +1,31 @@
import { Badge, Box, Button, Group } from "@mantine/core";
import { Button } from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { useTranslation } from "react-i18next";
import type { FileTypeMeta } from "@app/components/viewer/nonpdf/types";
interface NonPdfBannerProps {
meta: FileTypeMeta;
onConvertToPdf?: () => void;
}
export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) {
export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) {
const { t } = useTranslation();
if (!onConvertToPdf) return null;
return (
<Group
gap="xs"
wrap="nowrap"
align="center"
<Button
size="xs"
variant="light"
color="orange"
leftSection={<PictureAsPdfIcon style={{ fontSize: "0.9rem" }} />}
onClick={onConvertToPdf}
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
pointerEvents: "auto",
}}
>
<Badge
variant="default"
size="lg"
leftSection={
<Box
style={{
display: "flex",
alignItems: "center",
fontSize: "0.85rem",
}}
>
{meta.icon}
</Box>
}
style={{ fontWeight: 600 }}
>
{t("viewer.nonPdf.fileTypeBadge", { type: meta.label })}
</Badge>
{onConvertToPdf && (
<Button
size="xs"
variant="light"
color="orange"
leftSection={<PictureAsPdfIcon style={{ fontSize: "0.9rem" }} />}
onClick={onConvertToPdf}
>
{t("viewer.nonPdf.convertToPdf")}
</Button>
)}
</Group>
{t("viewer.nonPdf.convertToPdf")}
</Button>
);
}
@@ -182,10 +182,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
{/* Toolbar */}
<Paper
radius={0}
p="xs"
px="sm"
style={{
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
minHeight: 44,
display: "flex",
alignItems: "center",
}}
>
<Group gap="md" align="center">
@@ -121,7 +121,6 @@ export function useViewerRightRailButtons(
);
const layersLabel = t("rightRail.toggleLayers", "Toggle Layers");
const commentsLabel = t("rightRail.toggleComments", "Comments");
const printLabel = t("rightRail.print", "Print PDF");
const annotationsLabel = t("rightRail.annotations", "Annotations");
const formFillLabel = t("rightRail.formFill", "Fill Form");
const rulerLabel = t("rightRail.ruler", "Ruler / Measure");
@@ -179,12 +178,12 @@ export function useViewerRightRailButtons(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
className="workbench-bar-action-icon"
disabled={disabled}
aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
<LocalIcon icon="search" width="1.25rem" height="1.25rem" />
</ActionIcon>
</div>
</Popover.Target>
@@ -203,7 +202,7 @@ export function useViewerRightRailButtons(
{
id: "viewer-pan-mode",
icon: (
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon icon="pan-tool-rounded" width="1.25rem" height="1.25rem" />
),
tooltip:
!isPanning && pendingCount > 0 && redactionActiveType !== null
@@ -220,12 +219,16 @@ export function useViewerRightRailButtons(
!isPanning && pendingCount > 0 && redactionActiveType !== null,
onClick: () => {
viewer.panActions.togglePan();
setIsPanning((prev) => !prev);
setIsPanning((prev) => {
const next = !prev;
if (next && isRulerActive) setIsRulerActive?.(false);
return next;
});
},
},
{
id: "viewer-ruler",
icon: <StraightenIcon sx={{ fontSize: "1.5rem" }} />,
icon: <StraightenIcon sx={{ fontSize: "1.25rem" }} />,
tooltip: rulerLabel,
ariaLabel: rulerLabel,
section: "top" as const,
@@ -236,12 +239,13 @@ export function useViewerRightRailButtons(
setIsRulerActive?.(next);
if (next && isPanning) {
viewer.panActions.disablePan();
setIsPanning(false);
}
},
},
{
id: "viewer-rotate-left",
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
icon: <LocalIcon icon="rotate-left" width="1.25rem" height="1.25rem" />,
tooltip: rotateLeftLabel,
ariaLabel: rotateLeftLabel,
section: "top" as const,
@@ -252,7 +256,9 @@ export function useViewerRightRailButtons(
},
{
id: "viewer-rotate-right",
icon: <LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon icon="rotate-right" width="1.25rem" height="1.25rem" />
),
tooltip: rotateRightLabel,
ariaLabel: rotateRightLabel,
section: "top" as const,
@@ -263,7 +269,7 @@ export function useViewerRightRailButtons(
},
{
id: "viewer-toggle-sidebar",
icon: <LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />,
icon: <LocalIcon icon="view-list" width="1.25rem" height="1.25rem" />,
tooltip: sidebarLabel,
ariaLabel: sidebarLabel,
section: "top" as const,
@@ -278,8 +284,8 @@ export function useViewerRightRailButtons(
icon: (
<LocalIcon
icon="bookmark-add-rounded"
width="1.5rem"
height="1.5rem"
width="1.25rem"
height="1.25rem"
/>
),
tooltip: bookmarkLabel,
@@ -294,7 +300,11 @@ export function useViewerRightRailButtons(
{
id: "viewer-toggle-attachments",
icon: (
<LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="attachment-rounded"
width="1.25rem"
height="1.25rem"
/>
),
tooltip: attachmentLabel,
ariaLabel: attachmentLabel,
@@ -309,7 +319,7 @@ export function useViewerRightRailButtons(
? [
{
id: "viewer-toggle-layers",
icon: <LayersIcon sx={{ fontSize: "1.5rem" }} />,
icon: <LayersIcon sx={{ fontSize: "1.25rem" }} />,
tooltip: layersLabel,
ariaLabel: layersLabel,
section: "top" as const,
@@ -323,7 +333,7 @@ export function useViewerRightRailButtons(
: []),
{
id: "viewer-toggle-comments",
icon: <LocalIcon icon="comment" width="1.5rem" height="1.5rem" />,
icon: <LocalIcon icon="comment" width="1.25rem" height="1.25rem" />,
tooltip: commentsLabel,
ariaLabel: commentsLabel,
section: "top" as const,
@@ -333,17 +343,6 @@ export function useViewerRightRailButtons(
toggleCommentsSidebar();
},
},
{
id: "viewer-print",
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
tooltip: printLabel,
ariaLabel: printLabel,
section: "top" as const,
order: 57,
onClick: () => {
viewer.printActions.print();
},
},
{
id: "viewer-read-aloud",
tooltip: readAloudLabel,
@@ -373,7 +372,7 @@ export function useViewerRightRailButtons(
<ActionIcon
variant={isReadingAloud ? "filled" : "subtle"}
radius="md"
className="right-rail-icon"
className="workbench-bar-action-icon"
disabled={
disabled ||
typeof window === "undefined" ||
@@ -384,9 +383,9 @@ export function useViewerRightRailButtons(
color={isReadingAloud ? "blue" : undefined}
>
{isReadingAloud ? (
<StopIcon sx={{ fontSize: "1.5rem" }} />
<StopIcon sx={{ fontSize: "1.25rem" }} />
) : (
<VolumeUpIcon sx={{ fontSize: "1.5rem" }} />
<VolumeUpIcon sx={{ fontSize: "1.25rem" }} />
)}
</ActionIcon>
</Tooltip>
@@ -461,7 +460,7 @@ export function useViewerRightRailButtons(
<ActionIcon
variant={isAnnotationsActive ? "filled" : "subtle"}
radius="md"
className="right-rail-icon"
className="workbench-bar-action-icon"
onClick={() => {
if (disabled || isAnnotationsActive) return;
@@ -489,7 +488,7 @@ export function useViewerRightRailButtons(
aria-pressed={isAnnotationsActive}
color={isAnnotationsActive ? "blue" : undefined}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
<LocalIcon icon="edit" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Tooltip>
),
@@ -519,7 +518,7 @@ export function useViewerRightRailButtons(
<ActionIcon
variant={isFormFillActive ? "filled" : "subtle"}
radius="md"
className="right-rail-icon"
className="workbench-bar-action-icon"
onClick={() => {
if (disabled) return;
if (isFormFillActive) {
@@ -532,7 +531,7 @@ export function useViewerRightRailButtons(
aria-pressed={isFormFillActive}
color={isFormFillActive ? "blue" : undefined}
>
<TextFieldsIcon sx={{ fontSize: "1.5rem" }} />
<TextFieldsIcon sx={{ fontSize: "1.25rem" }} />
</ActionIcon>
</Tooltip>
),
@@ -560,7 +559,6 @@ export function useViewerRightRailButtons(
bookmarkLabel,
attachmentLabel,
layersLabel,
printLabel,
tooltipPosition,
annotationsLabel,
isAnnotationsActive,
+6 -8
View File
@@ -480,7 +480,7 @@ function FileContextInner({
const allIds = [activeEncryptedFileId, ...encryptedQueue];
let successCount = 0;
const failedNames: string[] = [];
const failedIds: FileId[] = [];
for (const fileId of allIds) {
try {
@@ -488,8 +488,7 @@ function FileContextInner({
dismissedEncryptedFilesRef.current.delete(fileId);
successCount++;
} catch {
const name = stateRef.current.files.byId[fileId]?.name ?? fileId;
failedNames.push(name);
failedIds.push(fileId);
}
}
@@ -506,17 +505,16 @@ function FileContextInner({
});
}
if (failedNames.length > 0) {
if (failedIds.length > 0) {
const failedNames = failedIds.map(
(id) => stateRef.current.files.byId[id]?.name ?? id,
);
setUnlockError(
t("encryptedPdfUnlock.unlockAllPartialFail", {
defaultValue: "Wrong password for: {{names}}",
names: failedNames.join(", "),
}),
);
const failedIds = allIds.filter((id) => {
const name = stateRef.current.files.byId[id]?.name ?? id;
return failedNames.includes(name);
});
setEncryptedQueue(failedIds.slice(1));
setActiveEncryptedFileId(failedIds[0]);
} else {
+133 -24
View File
@@ -9,7 +9,7 @@ import React, {
} from "react";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import { useFileActions, useFileManagement } from "@app/contexts/FileContext";
import { zipFileService } from "@app/services/zipFileService";
import { StirlingFileStub } from "@app/types/fileContext";
import { downloadFiles } from "@app/utils/downloadUtils";
@@ -91,10 +91,12 @@ interface FileManagerProviderProps {
isFileSupported: (fileName: string) => boolean;
isOpen: boolean;
onFileRemove: (index: number) => void;
onBulkRemove?: (fileIds: FileId[]) => void;
modalHeight: string;
refreshRecentFiles: () => Promise<void>;
isLoading: boolean;
activeFileIds: FileId[];
maxSelectable?: number | null;
}
type RemoteDeleteChoice = "local" | "server" | "both" | "leave" | "cancel";
@@ -108,10 +110,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
isFileSupported,
isOpen,
onFileRemove,
onBulkRemove,
modalHeight,
refreshRecentFiles,
isLoading,
activeFileIds,
maxSelectable = null,
}) => {
const [activeSource, setActiveSource] = useState<
"recent" | "local" | "drive"
@@ -119,7 +123,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [storageFilter, setStorageFilter] = useState<
"all" | "local" | "sharedWithMe" | "sharedByMe"
>("all");
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>(
() => activeFileIds,
);
const [searchTerm, setSearchTerm] = useState("");
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(
@@ -136,6 +142,14 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
>(null);
const { t } = useTranslation();
const { actions } = useFileActions();
const { removeFiles } = useFileManagement();
// Re-seed selection with active file IDs each time the modal opens
useEffect(() => {
if (isOpen) {
setSelectedFileIds(activeFileIds);
}
}, [isOpen, activeFileIds]);
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set());
@@ -284,6 +298,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedSet.delete(fileId);
} else {
selectedSet.add(fileId);
// Enforce maxSelectable: drop the oldest selection if over the limit
if (maxSelectable != null && selectedSet.size > maxSelectable) {
const [oldest] = selectedSet;
selectedSet.delete(oldest);
}
}
return Array.from(selectedSet);
@@ -293,7 +312,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setLastClickedIndex(currentIndex);
}
},
[filteredFiles, lastClickedIndex],
[filteredFiles, lastClickedIndex, maxSelectable],
);
// Helper function to safely determine which files can be deleted
@@ -389,7 +408,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Shared internal delete logic
const performFileDelete = useCallback(
async (fileToRemove: StirlingFileStub, fileIndex: number) => {
async (fileToRemove: StirlingFileStub) => {
let deleteChoice: RemoteDeleteChoice = "local";
if (fileToRemove.remoteStorageId) {
deleteChoice = await requestDeleteChoice(fileToRemove);
@@ -548,8 +567,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
console.error("Failed to delete files from chain:", error);
}
// Call the parent's deletion logic for the main file only
onFileRemove(fileIndex);
// ID-based removal avoids stale index issues when multiple server files are deleted sequentially.
onBulkRemove?.(filesToDelete as FileId[]);
// Refresh to ensure consistent state
await refreshRecentFiles();
@@ -559,7 +578,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setSelectedFileIds,
setExpandedFileIds,
setLoadedHistoryFiles,
onFileRemove,
onBulkRemove,
refreshRecentFiles,
requestDeleteChoice,
t,
@@ -570,7 +589,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
async (index: number) => {
const fileToRemove = filteredFiles[index];
if (fileToRemove) {
await performFileDelete(fileToRemove, index);
await performFileDelete(fileToRemove);
}
},
[filteredFiles, performFileDelete],
@@ -584,7 +603,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const fileToRemove = filteredFiles[fileIndex];
if (fileToRemove && fileIndex !== -1) {
await performFileDelete(fileToRemove, fileIndex);
await performFileDelete(fileToRemove);
}
},
[filteredFiles, performFileDelete],
@@ -646,11 +665,34 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
);
const handleOpenFiles = useCallback(() => {
if (selectedFiles.length > 0) {
onRecentFilesSelected(selectedFiles);
onClose();
// Remove active files that were unchecked, skipping files with unsaved changes
const uncheckedActiveIds = activeFileIds.filter((id) => {
if (selectedFilesSet.has(id)) return false;
const stub = filteredFiles.find((f) => f.id === id);
return !stub?.isDirty;
});
if (uncheckedActiveIds.length > 0) {
removeFiles(uncheckedActiveIds, false);
}
}, [selectedFiles, onRecentFilesSelected, onClose]);
// Add newly checked files (not already active)
const newlySelected = selectedFiles.filter(
(f) => !activeFileIds.includes(f.id),
);
if (newlySelected.length > 0) {
onRecentFilesSelected(newlySelected);
}
onClose();
}, [
selectedFiles,
selectedFilesSet,
activeFileIds,
filteredFiles,
removeFiles,
onRecentFilesSelected,
onClose,
]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
@@ -683,24 +725,91 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setSelectedFileIds([]);
setLastClickedIndex(null);
} else {
// Select all filtered files
setSelectedFileIds(filteredFiles.map((file) => file.id).filter(Boolean));
// Select all filtered files, capped at maxSelectable
const allIds = filteredFiles
.map((file) => file.id)
.filter(Boolean) as FileId[];
setSelectedFileIds(
maxSelectable != null ? allIds.slice(0, maxSelectable) : allIds,
);
setLastClickedIndex(null);
}
}, [filteredFiles, selectedFileIds]);
}, [filteredFiles, selectedFileIds, maxSelectable]);
const handleDeleteSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Delete each selected file using the proven single delete logic
for (const fileId of selectedFileIds) {
await handleFileRemoveById(fileId);
}
} catch (error) {
console.error("Failed to delete selected files:", error);
// Split into server files (need modal prompts) and local files (bulk path).
const serverFiles = selectedFileIds
.map((id) => filteredFiles.find((f) => f.id === id))
.filter((f): f is StirlingFileStub => !!f && !!f.remoteStorageId);
const localIds = selectedFileIds.filter((id) => {
const f = filteredFiles.find((ff) => ff.id === id);
return f && !f.remoteStorageId;
}) as FileId[];
// Server files — sequential, each may need a modal prompt.
for (const file of serverFiles) {
await performFileDelete(file);
}
}, [selectedFileIds, handleFileRemoveById]);
// Local files — single IDB transaction, one UI update.
if (localIds.length > 0) {
try {
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
const safeIds = getSafeFilesToDelete(
localIds,
allStoredStubs,
) as FileId[];
const safeIdSet = new Set(safeIds);
// Clear UI state in one pass.
setSelectedFileIds((prev) => prev.filter((id) => !safeIdSet.has(id)));
setExpandedFileIds((prev) => {
const next = new Set(prev);
safeIds.forEach((id) => next.delete(id));
return next;
});
setLoadedHistoryFiles((prev) => {
const next = new Map(prev);
safeIds.forEach((id) => next.delete(id));
for (const [mainId, histFiles] of next.entries()) {
const filtered = histFiles.filter((h) => !safeIdSet.has(h.id));
if (filtered.length !== histFiles.length)
next.set(mainId, filtered);
}
return next;
});
// Optimistic UI update.
onBulkRemove?.(safeIds);
// Remove from workbench.
removeFiles(safeIds, false);
// Single-transaction IDB delete, then refresh.
fileStorage
.deleteMultipleStirlingFiles(safeIds)
.then(() => refreshRecentFiles())
.catch((error) => {
console.error("Failed to bulk delete files from IndexedDB:", error);
refreshRecentFiles();
});
} catch (error) {
console.error("Failed to bulk delete local files:", error);
await refreshRecentFiles();
}
}
}, [
selectedFileIds,
filteredFiles,
performFileDelete,
getSafeFilesToDelete,
removeFiles,
refreshRecentFiles,
onBulkRemove,
]);
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
@@ -8,6 +8,10 @@ import React, {
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileActions } from "@app/contexts/FileContext";
import { useFileContext } from "@app/contexts/file/fileHooks";
import {
useNavigationActions,
useNavigationState,
} from "@app/contexts/NavigationContext";
import { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { fileStorage } from "@app/services/fileStorage";
@@ -26,8 +30,10 @@ interface FilesModalContextType {
openFilesModal: (options?: {
insertAfterPage?: number;
customHandler?: (files: File[], insertAfterPage?: number) => void;
maxSelectable?: number | null;
}) => void;
closeFilesModal: () => void;
maxSelectable: number | null;
onFileUpload: (files: File[]) => void;
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
onModalClose?: () => void;
@@ -42,12 +48,17 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
const { addFiles } = useFileHandler();
const { actions } = useFileActions();
const fileCtx = useFileContext();
const { actions: navActions } = useNavigationActions();
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
const isMultiTool =
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
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 [maxSelectable, setMaxSelectable] = useState<number | null>(null);
const importBundleToWorkbench = useCallback(
async (
@@ -220,9 +231,11 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
(options?: {
insertAfterPage?: number;
customHandler?: (files: File[], insertAfterPage?: number) => void;
maxSelectable?: number | null;
}) => {
setInsertAfterPage(options?.insertAfterPage);
setCustomHandler(() => options?.customHandler);
setMaxSelectable(options?.maxSelectable ?? null);
setIsFilesModalOpen(true);
},
[],
@@ -238,7 +251,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
const handleFileUpload = useCallback(
async (files: File[]) => {
if (customHandler) {
// Use custom handler for special cases (like page insertion)
// Use custom handler for special cases (like page insertion) — no auto-navigation
customHandler(files, insertAfterPage);
} else {
// 1) Add via standard flow (auto-selects new files)
@@ -256,6 +269,10 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
);
actions.setSelectedFiles(nextSelection);
}
// Stay in multi-tool; otherwise single file → viewer, multiple → active files
if (!isMultiTool) {
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
}
closeFilesModal();
},
@@ -266,6 +283,8 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
customHandler,
actions,
fileCtx,
navActions,
isMultiTool,
],
);
@@ -397,6 +416,12 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
console.error("addStirlingFileStubs action not available");
}
// Stay in multi-tool; otherwise single file → viewer, multiple → active files
if (!isMultiTool) {
const totalAdded = stirlingFileStubs.length;
navActions.setWorkbench(totalAdded === 1 ? "viewer" : "fileEditor");
}
closeFilesModal();
},
[
@@ -410,6 +435,8 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
downloadShareLinkFile,
extractLatestFilesFromBundle,
importBundleToWorkbench,
navActions,
isMultiTool,
],
);
@@ -426,6 +453,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
onRecentFileSelect: handleRecentFileSelect,
onModalClose,
setOnModalClose: setModalCloseCallback,
maxSelectable,
}),
[
isFilesModalOpen,
@@ -435,6 +463,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
handleRecentFileSelect,
onModalClose,
setModalCloseCallback,
maxSelectable,
],
);
+48 -22
View File
@@ -3,7 +3,13 @@
* Integrates with FileContext to provide transparent file persistence
*/
import React, { createContext, useContext, useCallback, useRef } from "react";
import React, {
createContext,
useContext,
useCallback,
useRef,
useState,
} from "react";
import { fileStorage } from "@app/services/fileStorage";
import { FileId } from "@app/types/file";
import {
@@ -40,6 +46,10 @@ interface IndexedDBContextValue {
}>;
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
// Incremented after any write or delete — subscribe to trigger re-reads
revision: number;
bumpRevision: () => void;
}
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
@@ -49,6 +59,9 @@ interface IndexedDBProviderProps {
}
export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const [revision, setRevision] = useState(0);
const bumpRevision = useCallback(() => setRevision((r) => r + 1), []);
// LRU File cache to avoid repeated ArrayBuffer→File conversions
const fileCache = useRef(
new Map<FileId, { file: File; lastAccessed: number }>(),
@@ -79,11 +92,13 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
fileId: FileId,
existingThumbnail?: string,
): Promise<StirlingFileStub> => {
// Use existing thumbnail or generate new one if none provided
const thumbnail =
existingThumbnail || (await generateThumbnailForFile(file));
// existingThumbnail="" means caller explicitly opted out of a raster thumbnail;
// only generate when the caller passed nothing (undefined).
const generated =
existingThumbnail ?? (await generateThumbnailForFile(file));
const thumbnail = generated || undefined;
// Store in IndexedDB (no history data - that's handled by direct fileStorage calls now)
// History is handled via direct fileStorage calls, not here
const stirlingFile = createStirlingFile(file, fileId);
// Create minimal stub for storage
@@ -116,9 +131,10 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
);
}
bumpRevision();
return storedFile;
},
[],
[bumpRevision],
);
const loadFile = useCallback(
@@ -155,13 +171,17 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
[],
);
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
// Remove from cache
fileCache.current.delete(fileId);
const deleteFile = useCallback(
async (fileId: FileId): Promise<void> => {
// Remove from cache
fileCache.current.delete(fileId);
// Remove from IndexedDB
await fileStorage.deleteStirlingFile(fileId);
}, []);
// Remove from IndexedDB
await fileStorage.deleteStirlingFile(fileId);
bumpRevision();
},
[bumpRevision],
);
const loadLeafMetadata = useCallback(async (): Promise<
StirlingFileStub[]
@@ -184,12 +204,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Remove from cache
fileIds.forEach((id) => fileCache.current.delete(id));
// Remove from IndexedDB in parallel
await Promise.all(
fileIds.map((id) => fileStorage.deleteStirlingFile(id)),
);
// Delete all in a single IDB transaction
await fileStorage.deleteMultipleStirlingFiles(fileIds);
bumpRevision();
},
[],
[bumpRevision],
);
const clearAll = useCallback(async (): Promise<void> => {
@@ -198,7 +217,8 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Clear IndexedDB
await fileStorage.clearAll();
}, []);
bumpRevision();
}, [bumpRevision]);
const getStorageStats = useCallback(async () => {
return await fileStorage.getStorageStats();
@@ -206,16 +226,20 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const updateThumbnail = useCallback(
async (fileId: FileId, thumbnail: string): Promise<boolean> => {
return await fileStorage.updateThumbnail(fileId, thumbnail);
const result = await fileStorage.updateThumbnail(fileId, thumbnail);
if (result) bumpRevision();
return result;
},
[],
[bumpRevision],
);
const markFileAsProcessed = useCallback(
async (fileId: FileId): Promise<boolean> => {
return await fileStorage.markFileAsProcessed(fileId);
const result = await fileStorage.markFileAsProcessed(fileId);
if (result) bumpRevision();
return result;
},
[],
[bumpRevision],
);
const value: IndexedDBContextValue = {
@@ -230,6 +254,8 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
getStorageStats,
updateThumbnail,
markFileAsProcessed,
revision,
bumpRevision,
};
return (
@@ -214,6 +214,8 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
return [...validOrder, ...newIds];
});
clearPersistedDocument();
// Select all workbench files so they render as expanded pages
fileActions.setSelectedFiles(state.files.ids);
}
prevWorkbenchRef.current = nextWorkbench;
@@ -223,6 +225,7 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
state.files.ids,
setFileOrder,
setReorderedPages,
fileActions,
]);
const fileContextSignature = useMemo(() => {
@@ -355,6 +355,29 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setLeftPanelView,
]);
// When in multi-tool, sync left panel visibility with workbench:
// hide the panel on pageEditor, show it when navigating to viewer/fileEditor.
const prevMultiToolWorkbenchRef = React.useRef<WorkbenchType | null>(null);
useEffect(() => {
const prev = prevMultiToolWorkbenchRef.current;
prevMultiToolWorkbenchRef.current = navigationState.workbench;
if (navigationState.selectedTool !== "multiTool") return;
if (navigationState.workbench === "pageEditor" && prev !== "pageEditor") {
setLeftPanelView("hidden");
} else if (
navigationState.workbench !== "pageEditor" &&
prev === "pageEditor"
) {
setLeftPanelView("toolPicker");
}
}, [
navigationState.workbench,
navigationState.selectedTool,
setLeftPanelView,
]);
// Tool reset methods
const registerToolReset = useCallback(
(toolId: string, resetFunction: () => void) => {
+33 -1
View File
@@ -2,11 +2,15 @@ import React, {
createContext,
useContext,
useState,
useMemo,
useEffect,
ReactNode,
useRef,
useCallback,
} from "react";
import { useNavigation } from "@app/contexts/NavigationContext";
import { useFileState } from "@app/contexts/FileContext";
import { isStirlingFile } from "@app/types/fileContext";
import {
preferencesService,
type PdfRenderMode,
@@ -233,7 +237,35 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileId, setActiveFileId] = useState<string | null>(null);
const [activeFileIndex, setActiveFileIndex] = useState(0);
// activeFileIndex is derived from activeFileId so they can never desync.
// ViewerProvider sits inside FileContextProvider so useFileState is valid here.
const { selectors, state } = useFileState();
// Clear activeFileId when its file is removed from the workbench.
// Dep on state.files.ids so the effect re-runs on every add/remove.
useEffect(() => {
if (!activeFileId) return;
const stillInWorkbench = state.files.ids.some(
(id) => (id as string) === activeFileId,
);
if (!stillInWorkbench) setActiveFileId(null);
}, [activeFileId, state.files.ids]);
const activeFileIndex = useMemo(() => {
if (!activeFileId) return 0;
const files = selectors.getFiles();
const idx = files.findIndex((f) => f.fileId === activeFileId);
return idx >= 0 ? idx : 0;
}, [activeFileId, selectors]);
const setActiveFileIndex = useCallback(
(index: number) => {
const files = selectors.getFiles();
const file = files[index];
if (file && isStirlingFile(file)) setActiveFileId(file.fileId);
},
[selectors],
);
const [pdfRenderMode, setPdfRenderModeState] = useState<PdfRenderMode>(() =>
preferencesService.getPreference("pdfRenderMode"),
);
+74 -79
View File
@@ -347,6 +347,9 @@ export async function addFiles(
`📄 addFiles: After ZIP processing, ${filesToProcess.length} files to add`,
);
// Collect hydrations to schedule after dispatch so updateStirlingFileStub finds files in state.
const pendingHydrations: Array<() => Promise<void>> = [];
for (const file of filesToProcess) {
const quickKey = createQuickKey(file);
@@ -419,18 +422,12 @@ export async function addFiles(
}
stirlingFileStubs.push(fileStub);
// Dispatch immediately so each file appears as soon as it is processed
dispatch({
type: "ADD_FILES",
payload: { stirlingFileStubs: [fileStub] },
});
// Create StirlingFile directly
const stirlingFile = createStirlingFile(file, fileId);
stirlingFiles.push(stirlingFile);
// Queue background hydration so add flow doesn't block on thumbnail/metadata work
scheduleMetadataHydration(async () => {
// Capture per-file hydration task — scheduled after batch dispatch below
pendingHydrations.push(async () => {
const targetFile = filesRef.current.get(fileId);
if (!targetFile) {
return;
@@ -483,9 +480,34 @@ export async function addFiles(
if (Object.keys(updates).length > 0) {
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef);
}
// Persist the thumbnail to IndexedDB so it's available in future sessions.
// The file was stored before hydration ran, so it had no thumbnail yet.
// Skip blob URLs — they're session-only and won't be valid after reload.
if (
primaryThumbnail &&
enablePersistence &&
!primaryThumbnail.startsWith("blob:")
) {
try {
await fileStorage.updateThumbnail(fileId, primaryThumbnail);
} catch {
// Non-critical — regenerated lazily on next hover
}
}
});
}
// Batch dispatch all files at once — one render instead of N sequential renders
if (stirlingFileStubs.length > 0) {
dispatch({ type: "ADD_FILES", payload: { stirlingFileStubs } });
}
// Schedule hydrations after dispatch so updateStirlingFileStub finds files in state
for (const task of pendingHydrations) {
scheduleMetadataHydration(task);
}
// Persist to storage if enabled using fileStorage service
if (enablePersistence && stirlingFiles.length > 0) {
await Promise.all(
@@ -635,56 +657,6 @@ export async function consumeFiles(
}
/**
* Helper function to restore files to filesRef and manage IndexedDB cleanup
*/
async function restoreFilesAndCleanup(
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
fileIdsToRemove: FileId[],
filesRef: React.MutableRefObject<Map<FileId, File>>,
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null,
): Promise<void> {
// Remove files from filesRef
fileIdsToRemove.forEach((id) => {
if (filesRef.current.has(id)) {
if (DEBUG) console.log(`📄 Removing file ${id} from filesRef`);
filesRef.current.delete(id);
} else {
if (DEBUG) console.warn(`📄 File ${id} not found in filesRef`);
}
});
// Restore files to filesRef
filesToRestore.forEach(({ file, record }) => {
if (file && record) {
// Validate the file before restoring
if (file.size === 0) {
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
return;
}
// Restore the file to filesRef
if (DEBUG)
console.log(
`📄 Restoring file ${file.name} with id ${record.id} to filesRef`,
);
filesRef.current.set(record.id, file);
}
});
// Clean up IndexedDB
if (indexedDB) {
const indexedDBPromises = fileIdsToRemove.map((fileId) =>
indexedDB.deleteFile(fileId).catch((error) => {
console.error("Failed to delete file from IndexedDB:", fileId, error);
throw error; // Re-throw to trigger rollback
}),
);
// Execute all IndexedDB operations
await Promise.all(indexedDBPromises);
}
}
/**
* Undoes a previous consumeFiles operation by restoring input files and removing output files (unless pinned)
*/
@@ -701,6 +673,7 @@ export async function undoConsumeFiles(
existingThumbnail?: string,
) => Promise<any>;
deleteFile: (fileId: FileId) => Promise<void>;
bumpRevision?: () => void;
} | null,
): Promise<void> {
if (DEBUG)
@@ -719,30 +692,21 @@ export async function undoConsumeFiles(
const backupFilesRef = new Map(filesRef.current);
try {
// Prepare files to restore
const filesToRestore = inputFiles.map((file, index) => ({
file,
record: inputStirlingFileStubs[index],
}));
// Restore input files and clean up output files
await restoreFilesAndCleanup(
filesToRestore,
outputFileIds,
filesRef,
indexedDB,
);
// Mark restored files as dirty if they have localFilePath
// (they now differ from what's saved on disk)
const stubsWithDirtyMarked = inputStirlingFileStubs.map((stub) => {
if (stub.localFilePath) {
return { ...stub, isDirty: true };
// Sync filesRef before dispatch — prevents bumpRevision re-renders from seeing stale output IDs with no File objects.
outputFileIds.forEach((id) => filesRef.current.delete(id));
inputFiles.forEach((file, index) => {
const record = inputStirlingFileStubs[index];
if (file && record && file.size > 0) {
filesRef.current.set(record.id, file);
}
return stub;
});
// Dispatch the undo action (only if everything else succeeded)
// Mark restored files dirty if they have a local path (they now differ from disk).
const stubsWithDirtyMarked = inputStirlingFileStubs.map((stub) =>
stub.localFilePath ? { ...stub, isDirty: true } : stub,
);
// Dispatch with filesRef and state.files.ids now in sync.
dispatch({
type: "UNDO_CONSUME_FILES",
payload: {
@@ -751,6 +715,33 @@ export async function undoConsumeFiles(
},
});
// IDB cleanup fire-and-forget — state is already consistent when bumpRevision fires.
if (indexedDB) {
outputFileIds.forEach((fileId) => {
indexedDB.deleteFile(fileId).catch((error) => {
console.error(
"📄 undoConsumeFiles: Failed to delete output file from IDB:",
fileId,
error,
);
// Bump revision so the sidebar re-reads IDB and orphaned files reappear.
indexedDB.bumpRevision?.();
});
});
}
// Restore isLeaf in IDB — modal reads IDB directly and misses files if isLeaf=false.
await Promise.all(
inputStirlingFileStubs.map((stub) =>
fileStorage.markFileAsLeaf(stub.id).catch((error) => {
console.warn(
`📄 undoConsumeFiles: Failed to restore isLeaf for ${stub.id}:`,
error,
);
}),
),
);
if (DEBUG)
console.log(
`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`,
@@ -883,9 +874,13 @@ export async function addStirlingFileStubs(
updates,
stateRef,
);
return;
}
}
}
// Stub dispatch triggers re-render so the viewer appears (ADD_FILES alone doesn't update selectors).
lifecycleManager.updateStirlingFileStub(fileId, {}, stateRef);
});
}
@@ -20,6 +20,7 @@ export interface ZoomActions {
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: any, center?: any) => void;
setZoomLevel: (factor: number) => void;
}
export interface PanActions {
@@ -217,6 +218,13 @@ export function createViewerActions({
api.requestZoom(level, center);
}
},
setZoomLevel: (factor: number) => {
const api = registry.current.zoom?.api;
if (api?.requestZoom) {
triggerImmediateZoomUpdate(Math.round(factor * 100));
api.requestZoom(factor);
}
},
};
const panActions: PanActions = {
@@ -105,7 +105,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
multiTool: {
icon: (
<LocalIcon
icon="dashboard-customize-rounded"
icon="dashboard-customize-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
@@ -128,7 +128,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
merge: {
icon: (
<LocalIcon
icon="library-add-rounded"
icon="library-add-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
@@ -153,7 +153,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
certSign: {
icon: (
<LocalIcon
icon="workspace-premium-rounded"
icon="workspace-premium-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
@@ -177,7 +177,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
timestampPdf: {
icon: (
<LocalIcon icon="schedule-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="schedule-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.timestampPdf.title", "Timestamp PDF"),
component: lazy(() => import("@app/tools/TimestampPdf")),
@@ -236,7 +240,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
},
addImage: {
icon: <LocalIcon icon="image-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="image-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.addImage.title", "Add Image"),
component: lazy(() => import("@app/tools/AddImage")),
description: t("home.addImage.desc", "Add images anywhere in your PDF"),
@@ -249,7 +259,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
},
annotate: {
icon: <LocalIcon icon="edit" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="edit-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.annotate.title", "Annotate"),
component: lazy(() => import("@app/tools/Annotate")),
description: t(
@@ -315,7 +331,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
addStamp: {
icon: (
<LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="approval-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.addStamp.title", "Add Stamp to PDF"),
component: lazy(() => import("@app/tools/AddStamp")),
@@ -361,7 +381,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
flatten: {
icon: (
<LocalIcon
icon="layers-clear-rounded"
icon="layers-clear-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
@@ -447,7 +467,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
getPdfInfo: {
icon: (
<LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="fact-check-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
component: lazy(() => import("@app/tools/GetPdfInfo")),
@@ -465,7 +489,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
validateSignature: {
icon: (
<LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="verified-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.validateSignature.title", "Validate PDF Signature"),
component: lazy(() => import("@app/tools/ValidateSignature")),
@@ -485,7 +513,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
read: {
icon: (
<LocalIcon icon="article-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="article-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.read.title", "Read"),
component: null,
@@ -503,7 +535,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
changeMetadata: {
icon: (
<LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="assignment-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.changeMetadata.title", "Change Metadata"),
component: lazy(() => import("@app/tools/ChangeMetadata")),
@@ -658,7 +694,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
pageLayout: {
icon: (
<LocalIcon icon="dashboard-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="dashboard-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.pageLayout.title", "Multi-Page Layout"),
component: lazy(() => import("@app/tools/PageLayout")),
@@ -677,7 +717,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
bookletImposition: {
icon: (
<LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="menu-book-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.bookletImposition.title", "Booklet Imposition"),
component: lazy(() => import("@app/tools/BookletImposition")),
@@ -764,7 +808,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
extractImages: {
icon: (
<LocalIcon
icon="photo-library-rounded"
icon="photo-library-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
@@ -816,7 +860,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
removeBlanks: {
icon: (
<LocalIcon
icon="scan-delete-rounded"
icon="scan-delete-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
@@ -971,7 +1015,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
// Advanced Formatting
adjustContrast: {
icon: <LocalIcon icon="palette" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon icon="palette-outline" width="1.5rem" height="1.5rem" />
),
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
component: lazy(() => import("@app/tools/AdjustContrast")),
description: t(
@@ -1013,7 +1059,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
scannerImageSplit: {
icon: (
<LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="scanner-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t(
"home.scannerImageSplit.title",
@@ -1037,7 +1087,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
overlayPdfs: {
icon: (
<LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="layers-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
component: lazy(() => import("@app/tools/OverlayPdfs")),
@@ -1081,7 +1135,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
scannerEffect: {
icon: (
<LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />
<LocalIcon
icon="scanner-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.scannerEffect.title", "Scanner Effect"),
component: null,
@@ -1314,7 +1372,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
redact: {
icon: (
<LocalIcon
icon="visibility-off-rounded"
icon="visibility-off-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { extractPDFMetadata } from "@app/services/pdfMetadataService";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
interface MetadataExtractionParams {
@@ -11,7 +11,7 @@ interface MetadataExtractionParams {
}
export const useMetadataExtraction = (params: MetadataExtractionParams) => {
const { selectedFiles } = useSelectedFiles();
const selectedFiles = useViewScopedFiles();
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false);
const previousFileCountRef = useRef(0);
@@ -51,7 +51,7 @@ export const useGetPdfInfoOperation = (): GetPdfInfoOperationHook => {
const executeOperation = useCallback(
async (_params: GetPdfInfoParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t("noFileSelected", "No files selected"));
setErrorMessage(t("noFileSelected", "No file loaded"));
return;
}
@@ -17,6 +17,7 @@ export enum ToolType {
*/
export type ExecuteDisabledReason =
| "endpointUnavailable"
| "filesLoading"
| "noFiles"
| "invalidParams"
| "viewerMode"
@@ -121,7 +121,7 @@ export const useToolOperation = <TParams>(
async (params: TParams, selectedFiles: StirlingFile[]): Promise<void> => {
// Validation
if (selectedFiles.length === 0) {
actions.setError(t("noFileSelected", "No files selected"));
actions.setError(t("noFileSelected", "No file loaded"));
return;
}
@@ -459,7 +459,7 @@ export const useToolOperation = <TParams>(
inputStirlingFileStubs[0],
newToolOperation,
file,
thumbnail,
metadata?.thumbnailUrl || thumbnail,
metadata,
),
);
@@ -523,7 +523,7 @@ export const useToolOperation = <TParams>(
createNewStirlingFileStub(
file,
undefined,
thumbnail,
metadata?.thumbnailUrl || thumbnail,
metadata,
),
);
@@ -1,21 +1,13 @@
import { useMemo } from "react";
import { useAllFiles, useSelectedFiles } from "@app/contexts/FileContext";
import { useAllFiles } from "@app/contexts/FileContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { StirlingFile } from "@app/types/fileContext";
/**
* Returns the effective file set for tool operations.
*
* - Viewer: scopes to the single file currently shown, unless ignoreViewerScope is true.
* - FileEditor: scopes to the selected subset (empty selection → empty → button disabled).
* - PageEditor / custom workbenches: returns all loaded files (selection tracks pages, not files).
*/
export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] {
const { activeFileIndex } = useViewer();
const { files: allFiles } = useAllFiles();
const { workbench } = useNavigationState();
const { selectedFiles } = useSelectedFiles();
return useMemo(() => {
if (workbench === "viewer" && !ignoreViewerScope) {
@@ -23,10 +15,6 @@ export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] {
return viewerFile ? [viewerFile] : allFiles;
}
if (workbench === "fileEditor") {
return selectedFiles;
}
return allFiles;
}, [workbench, allFiles, activeFileIndex, selectedFiles, ignoreViewerScope]);
}, [workbench, allFiles, activeFileIndex, ignoreViewerScope]);
}
@@ -49,7 +49,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => {
const executeOperation = useCallback(
async (_params: ShowJSParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t("noFileSelected", "No files selected"));
setErrorMessage(t("noFileSelected", "No file loaded"));
return;
}
@@ -57,17 +57,15 @@ export function useToolPanelGeometry({
let left: number;
if (isRTL) {
// In RTL, QuickAccessBar is on the right, so start after it (using rect.right as the right edge)
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const quickAccessWidth = quickAccessRect ? quickAccessRect.width : 0;
width = Math.max(
360,
window.innerWidth - quickAccessWidth - rightOffset,
);
left = quickAccessWidth;
// RTL: panel is on the left, expands rightward
width = Math.max(360, window.innerWidth - rect.right - rightOffset);
left = rect.right;
} else {
width = Math.max(360, window.innerWidth - rect.left - rightOffset);
left = rect.left;
// LTR: panel is on the right, expands leftward to the file sidebar
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
width = Math.max(360, rect.right - leftOffset);
left = leftOffset;
}
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
+15 -7
View File
@@ -376,6 +376,8 @@ export const useFileManager = () => {
index: number,
files: StirlingFileStub[],
setFiles: (files: StirlingFileStub[]) => void,
onRemovedFromWorkbench?: (fileId: FileId) => void,
onDeleteFailed?: () => void,
) => {
const file = files[index];
if (!file.id) {
@@ -384,13 +386,19 @@ export const useFileManager = () => {
if (!indexedDB) {
throw new Error("IndexedDB context not available");
}
try {
await indexedDB.deleteFile(file.id);
setFiles(files.filter((_, i) => i !== index));
} catch (error) {
console.error("Failed to remove file:", error);
throw error;
}
// Optimistic update — remove from UI immediately, delete IDB in background
setFiles(files.filter((_, i) => i !== index));
onRemovedFromWorkbench?.(file.id);
indexedDB.deleteFile(file.id).catch((error) => {
console.error("Failed to remove file from IndexedDB:", error);
// Restore consistency — file is still in IDB so refresh brings it back
if (onDeleteFailed) {
onDeleteFailed();
} else {
// No failure handler provided — refresh to restore consistent state
setFiles([...files]);
}
});
},
[indexedDB],
);
@@ -0,0 +1,19 @@
import { StirlingFileStub } from "@app/types/fileContext";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
export function useFileThumbnail(
fileStub: StirlingFileStub | null | undefined,
): {
isEncrypted: boolean;
thumbnail: string | null;
isGenerating: boolean;
} {
const isEncrypted = Boolean(fileStub?.processedFile?.isEncrypted);
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(
isEncrypted ? null : fileStub,
);
const thumbnail = isEncrypted
? null
: fileStub?.thumbnailUrl || indexedDBThumb || null;
return { isEncrypted, thumbnail, isGenerating };
}
+9 -1
View File
@@ -4,9 +4,15 @@ import { isFileObject } from "@app/types/fileContext";
/**
* Hook to convert a File object to { file: File; url: string } format
* Creates blob URL on-demand and revokes it when file changes or component unmounts.
*
* @param stableKey - Optional stable identity key (e.g. fileId). When provided, the blob
* URL is only recreated when this key changes, not when the `file` object reference
* changes. This prevents spurious URL churn caused by getFiles() creating new
* StirlingFile references on every FileContext render.
*/
export function useFileWithUrl(
file: File | Blob | null,
stableKey?: string | null,
): { file: File | Blob; url: string } | null {
const result = useMemo(() => {
if (!file) return null;
@@ -28,7 +34,9 @@ export function useFileWithUrl(
);
return null;
}
}, [file]);
// When stableKey is provided, only recreate when the file identity changes — not
// when getFiles() returns a new object reference for the same underlying file.
}, [stableKey != null ? stableKey : file]);
useEffect(() => {
const url = result?.url;
@@ -4,8 +4,10 @@ import { SidebarRefs } from "@app/types/sidebar";
export function useRightRailTooltipSide(
sidebarRefs?: SidebarRefs,
defaultOffset: number = 16,
): { position: "left" | "right"; offset: number } {
const [position, setPosition] = useState<"left" | "right">("left");
): { position: "left" | "right" | "bottom"; offset: number } {
const [position, setPosition] = useState<"left" | "right" | "bottom">(
"bottom",
);
useEffect(() => {
const computePosition = () => {
@@ -14,9 +16,9 @@ export function useRightRailTooltipSide(
typeof document !== "undefined" &&
document.documentElement.dir === "rtl";
// Fallback to left if we can't measure
// No rail visible — buttons are in the top bar, tooltips should point down
if (!rail || typeof window === "undefined") {
setPosition(isRTL ? "left" : "left");
setPosition("bottom");
return;
}
@@ -116,8 +116,10 @@ export function useTooltipPosition({
const tooltipRect = tooltipRef.current?.getBoundingClientRect() || null;
// Position adjacent to sidebar; mirror for RTL
if (isRTL) {
// Position adjacent to sidebar: left of panel when on the right half, right of panel when on left half; mirror for RTL
const panelCenter = rect.left + rect.width / 2;
const panelIsOnRight = panelCenter > window.innerWidth / 2;
if (isRTL ? !panelIsOnRight : panelIsOnRight) {
left = rect.left - (tooltipRect?.width || 0) - 20;
} else {
left = rect.right + 20;
+5 -6
View File
@@ -60,15 +60,14 @@ export function useNavigationUrlSync(
firePixel(currentPath);
const route = parseToolRoute(registry);
if (route.toolId !== selectedTool) {
if (route.toolId) {
if (route.toolId) {
// URL specifies a tool — navigate to it (URL takes precedence over startup view preference)
if (route.toolId !== selectedTool) {
checkPremiumAndSelect(route.toolId);
} else if (selectedTool !== null) {
// Only clear selection if we actually had a tool selected
// Don't clear on initial load when selectedTool starts as null
clearToolSelection();
}
}
// When the URL is the home path (no tool), leave selectedTool untouched so that
// the startup view preference (defaultStartupView) is respected.
hasInitialized.current = true;
}, [checkPremiumAndSelect, config, enableSync, registry, selectedTool]); // Include dependencies
+13 -4
View File
@@ -19,8 +19,8 @@ import AppsIcon from "@mui/icons-material/AppsRounded";
import ToolPanel from "@app/components/tools/ToolPanel";
import Workbench from "@app/components/layout/Workbench";
import QuickAccessBar from "@app/components/shared/QuickAccessBar";
import RightRail from "@app/components/shared/RightRail";
import FileSidebar from "@app/components/shared/FileSidebar";
import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
@@ -56,6 +56,7 @@ export default function HomePage() {
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(false);
const { activeFiles } = useFileContext();
const navigationState = useNavigationState();
@@ -366,11 +367,19 @@ export default function HomePage() {
</div>
) : (
<Group align="flex-start" gap={0} h="100%" className="flex-nowrap flex">
<QuickAccessBar ref={quickAccessRef} />
{!hideToolPanel && <ToolPanel />}
<FileSidebar
ref={quickAccessRef}
collapsed={fileSidebarCollapsed}
onToggleCollapse={() => setFileSidebarCollapsed((c) => !c)}
onOpenSettings={() => setConfigModalOpen(true)}
/>
<Workbench />
<RightRail />
{!hideToolPanel && <ToolPanel />}
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</Group>
)}
</div>

Some files were not shown because too many files have changed in this diff Show More