mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
UI redesign staging (#6149)
Co-authored-by: Reece Browne <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
James Brunton
parent
beb99e273b
commit
c731d5fd5d
@@ -183,26 +183,26 @@ Use this pattern for desktop-specific or proprietary-specific features WITHOUT r
|
|||||||
**Example - Desktop-specific footer:**
|
**Example - Desktop-specific footer:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
// core/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (stub)
|
||||||
interface RightRailFooterExtensionsProps {
|
interface WorkbenchBarFooterExtensionsProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
export function WorkbenchBarFooterExtensions(_props: WorkbenchBarFooterExtensionsProps) {
|
||||||
return null; // Stub - does nothing in web builds
|
return null; // Stub - does nothing in web builds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
// desktop/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (real implementation)
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||||
|
|
||||||
interface RightRailFooterExtensionsProps {
|
interface WorkbenchBarFooterExtensionsProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
export function WorkbenchBarFooterExtensions({ className }: WorkbenchBarFooterExtensionsProps) {
|
||||||
return (
|
return (
|
||||||
<Box className={className}>
|
<Box className={className}>
|
||||||
<BackendHealthIndicator />
|
<BackendHealthIndicator />
|
||||||
@@ -212,15 +212,15 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
|||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
// core/components/shared/WorkbenchBar.tsx (usage - works in ALL builds)
|
||||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
import { WorkbenchBarFooterExtensions } from '@app/components/workbenchBar/WorkbenchBarFooterExtensions';
|
||||||
|
|
||||||
export function RightRail() {
|
export function WorkbenchBar() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* In web builds: renders nothing (stub returns null) */}
|
{/* In web builds: renders nothing (stub returns null) */}
|
||||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||||
<RightRailFooterExtensions className="right-rail-footer" />
|
<WorkbenchBarFooterExtensions className="workbench-bar-footer" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ dist/
|
|||||||
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier)
|
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier)
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
public/vendor/
|
public/vendor/
|
||||||
public/pdfjs*/
|
public/pdfjs*/
|
||||||
public/js/thirdParty/
|
public/js/thirdParty/
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ exportAndContinue = "Export & Continue"
|
|||||||
false = "False"
|
false = "False"
|
||||||
fileNotSavedToDisk = "Not saved to disk"
|
fileNotSavedToDisk = "Not saved to disk"
|
||||||
fileSavedToDisk = "File saved to disk"
|
fileSavedToDisk = "File saved to disk"
|
||||||
fileSelected = "Selected: {{filename}}"
|
fileSelected = "{{filename}}"
|
||||||
filesSelected = "{{count}} files selected"
|
filesSelected = "{{count}} files"
|
||||||
font = "Font"
|
font = "Font"
|
||||||
fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text."
|
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."
|
fontTypeTooltip = "Font family for the page numbers. Choose based on your document style."
|
||||||
@@ -82,7 +82,7 @@ multiPdfPrompt = "Select PDFs (2+)"
|
|||||||
never = "Never"
|
never = "Never"
|
||||||
no = "No"
|
no = "No"
|
||||||
noFavourites = "No favourites added"
|
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"
|
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
|
||||||
noOperationToUndo = "No operation to undo"
|
noOperationToUndo = "No operation to undo"
|
||||||
notAuthenticatedMessage = "User not authenticated."
|
notAuthenticatedMessage = "User not authenticated."
|
||||||
@@ -1460,14 +1460,14 @@ inactive = "Inactive"
|
|||||||
|
|
||||||
[adminOnboarding]
|
[adminOnboarding]
|
||||||
adminTools = "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."
|
adminTools = "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."
|
||||||
configButton = "Click the <strong>Config</strong> button to access all system settings and administrative controls."
|
configButton = "Open <strong>Settings</strong> to access all system configuration and administrative controls."
|
||||||
connectionsSection = "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."
|
connectionsSection = "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."
|
||||||
databaseSection = "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."
|
databaseSection = "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."
|
||||||
settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."
|
settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."
|
||||||
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
|
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
|
||||||
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
|
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
|
||||||
welcome = "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."
|
welcome = "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."
|
||||||
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."
|
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||||
|
|
||||||
[adminUserSettings]
|
[adminUserSettings]
|
||||||
actions = "Actions"
|
actions = "Actions"
|
||||||
@@ -2728,6 +2728,7 @@ used = "used"
|
|||||||
[compare]
|
[compare]
|
||||||
addFilesHint = "Add PDFs in the Files step to enable selection."
|
addFilesHint = "Add PDFs in the Files step to enable selection."
|
||||||
clearSelected = "Clear selected"
|
clearSelected = "Clear selected"
|
||||||
|
clearSlot = "Remove file"
|
||||||
cta = "Compare"
|
cta = "Compare"
|
||||||
header = "Compare PDFs"
|
header = "Compare PDFs"
|
||||||
loading = "Comparing..."
|
loading = "Comparing..."
|
||||||
@@ -2753,7 +2754,7 @@ label = "Original document"
|
|||||||
placeholder = "Select the original PDF"
|
placeholder = "Select the original PDF"
|
||||||
|
|
||||||
[compare.clear]
|
[compare.clear]
|
||||||
confirm = "Clear and return"
|
confirm = "Clear Selected"
|
||||||
confirmBody = "This will close the current comparison and take you back to Active Files."
|
confirmBody = "This will close the current comparison and take you back to Active Files."
|
||||||
confirmTitle = "Clear selected PDFs?"
|
confirmTitle = "Clear selected PDFs?"
|
||||||
|
|
||||||
@@ -2779,6 +2780,7 @@ title = "These PDFs look highly different"
|
|||||||
|
|
||||||
[compare.edited]
|
[compare.edited]
|
||||||
label = "Edited PDF"
|
label = "Edited PDF"
|
||||||
|
selectBaseFirst = "Select original PDF first"
|
||||||
placeholder = "Select the edited PDF"
|
placeholder = "Select the edited PDF"
|
||||||
|
|
||||||
[compare.error]
|
[compare.error]
|
||||||
@@ -2860,6 +2862,7 @@ pageLabel = "Page"
|
|||||||
|
|
||||||
[compare.swap]
|
[compare.swap]
|
||||||
confirm = "Swap and Re-run"
|
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?"
|
confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?"
|
||||||
confirmTitle = "Re-run comparison?"
|
confirmTitle = "Re-run comparison?"
|
||||||
|
|
||||||
@@ -3109,7 +3112,7 @@ maintainAspectRatio = "Maintain Aspect Ratio"
|
|||||||
markdown = "Markdown"
|
markdown = "Markdown"
|
||||||
maxAttachmentSize = "Maximum attachment size (MB)"
|
maxAttachmentSize = "Maximum attachment size (MB)"
|
||||||
multiple = "Multiple"
|
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)"
|
odpExt = "OpenDocument Presentation (.odp)"
|
||||||
odtExt = "OpenDocument Text (.odt)"
|
odtExt = "OpenDocument Text (.odt)"
|
||||||
officeDocs = "Office Documents (Word, Excel, PowerPoint)"
|
officeDocs = "Office Documents (Word, Excel, PowerPoint)"
|
||||||
@@ -3130,9 +3133,9 @@ pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will b
|
|||||||
pptExt = "PowerPoint (.pptx)"
|
pptExt = "PowerPoint (.pptx)"
|
||||||
results = "Results"
|
results = "Results"
|
||||||
rtfExt = "Rich Text Format (.rtf)"
|
rtfExt = "Rich Text Format (.rtf)"
|
||||||
selectedFiles = "Selected files"
|
selectedFiles = "Active files"
|
||||||
selectFilesPlaceholder = "Select files in the main view to get started"
|
selectFilesPlaceholder = "Add files to the workbench to get started"
|
||||||
selectSourceFormatFirst = "Select a source format first"
|
selectSourceFormatFirst = "Choose a source format first"
|
||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
single = "Single"
|
single = "Single"
|
||||||
sourceFormatPlaceholder = "Source format"
|
sourceFormatPlaceholder = "Source format"
|
||||||
@@ -3287,7 +3290,7 @@ forRegularWork = "For regular PDF work:"
|
|||||||
[crop]
|
[crop]
|
||||||
autoCrop = "Auto-crop whitespace"
|
autoCrop = "Auto-crop whitespace"
|
||||||
header = "Crop PDF"
|
header = "Crop PDF"
|
||||||
noFileSelected = "Select a PDF file to begin cropping"
|
noFileSelected = "Add a PDF file to begin cropping"
|
||||||
reset = "Reset to full PDF"
|
reset = "Reset to full PDF"
|
||||||
submit = "Apply Crop"
|
submit = "Apply Crop"
|
||||||
title = "Crop"
|
title = "Crop"
|
||||||
@@ -3492,6 +3495,8 @@ unlockAll = "Use for all ({{count}})"
|
|||||||
unlockAllPartialFail = "Wrong password for: {{names}}"
|
unlockAllPartialFail = "Wrong password for: {{names}}"
|
||||||
unlockAllSuccess = "Unlocked {{count}} file(s)."
|
unlockAllSuccess = "Unlocked {{count}} file(s)."
|
||||||
unlockPrompt = "Unlock PDF to continue"
|
unlockPrompt = "Unlock PDF to continue"
|
||||||
|
viewerLocked = "This PDF is password-protected"
|
||||||
|
viewerUnlock = "Unlock"
|
||||||
|
|
||||||
[encryptedPdfUnlock.password]
|
[encryptedPdfUnlock.password]
|
||||||
label = "PDF password"
|
label = "PDF password"
|
||||||
@@ -3599,25 +3604,29 @@ or = "or"
|
|||||||
|
|
||||||
[fileEditor]
|
[fileEditor]
|
||||||
addFiles = "Add Files"
|
addFiles = "Add Files"
|
||||||
|
pageCount = "{{count}} pages"
|
||||||
|
pageCount_one = "{{count}} page"
|
||||||
|
pageCount_other = "{{count}} pages"
|
||||||
|
|
||||||
[fileManager]
|
[fileManager]
|
||||||
active = "Active"
|
active = "Active"
|
||||||
addToUpload = "Add to Upload"
|
addToUpload = "Add to Upload"
|
||||||
changesNotUploaded = "Changes not uploaded"
|
changesNotUploaded = "Changes not uploaded"
|
||||||
clearAll = "Clear All"
|
clearAll = "Clear All"
|
||||||
clearSelection = "Clear Selection"
|
clearSelection = "Clear"
|
||||||
clickToUpload = "Click to upload files"
|
clickToUpload = "Click to upload files"
|
||||||
|
closeAllFiles = "Close all files"
|
||||||
closeFile = "Close File"
|
closeFile = "Close File"
|
||||||
cloudFile = "Cloud file"
|
cloudFile = "Cloud file"
|
||||||
copyCreated = "Copy saved to this device."
|
copyCreated = "Copy saved to this device."
|
||||||
copyFailed = "Could not create a copy."
|
copyFailed = "Could not create a copy."
|
||||||
delete = "Delete"
|
delete = "Delete"
|
||||||
deleteAll = "Delete All"
|
deleteAll = "Delete All"
|
||||||
deleteSelected = "Delete Selected"
|
deleteSelected = "Delete Files"
|
||||||
deselectAll = "Deselect All"
|
deselectAll = "Uncheck All"
|
||||||
details = "File Details"
|
details = "File Details"
|
||||||
download = "Download"
|
download = "Download"
|
||||||
downloadSelected = "Download Selected"
|
downloadSelected = "Download Files"
|
||||||
dragDrop = "Drag & Drop files here"
|
dragDrop = "Drag & Drop files here"
|
||||||
dropFilesHere = "Drop files here"
|
dropFilesHere = "Drop files here"
|
||||||
failedToLoad = "Failed to load file to active set."
|
failedToLoad = "Failed to load file to active set."
|
||||||
@@ -3626,7 +3635,7 @@ fileFormat = "Format"
|
|||||||
fileHistory = "File History"
|
fileHistory = "File History"
|
||||||
fileName = "Name"
|
fileName = "Name"
|
||||||
fileSize = "Size"
|
fileSize = "Size"
|
||||||
filesSelected = "files selected"
|
filesSelected = "files"
|
||||||
filesStored = "files stored"
|
filesStored = "files stored"
|
||||||
fileVersion = "Version"
|
fileVersion = "Version"
|
||||||
filterAll = "All"
|
filterAll = "All"
|
||||||
@@ -3652,7 +3661,7 @@ mobileUpload = "Mobile Upload"
|
|||||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||||
myFiles = "My Files"
|
myFiles = "My Files"
|
||||||
noFiles = "No files available"
|
noFiles = "No files available"
|
||||||
noFileSelected = "No files selected"
|
noFileSelected = "No files chosen"
|
||||||
noFilesFound = "No files found matching your search"
|
noFilesFound = "No files found matching your search"
|
||||||
noRecentFiles = "No recent files found"
|
noRecentFiles = "No recent files found"
|
||||||
openFile = "Open File"
|
openFile = "Open File"
|
||||||
@@ -3675,18 +3684,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."
|
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?"
|
removeSharedServerOnlyPrompt = "This file is shared with you and stored only on the server. Remove it from your list?"
|
||||||
restore = "Restore"
|
restore = "Restore"
|
||||||
saveSelected = "Save Selected"
|
saveSelected = "Save Files"
|
||||||
searchFiles = "Search files..."
|
searchFiles = "Search files..."
|
||||||
selectAll = "Select All"
|
selectAll = "Check All"
|
||||||
selectedCount = "{{count}} selected"
|
selectedCount = "{{count}} checked"
|
||||||
selectedFiles = "Selected Files"
|
selectedFiles = "Files Added"
|
||||||
share = "Share"
|
share = "Share"
|
||||||
|
shared = "Shared"
|
||||||
sharedByYou = "Shared by you"
|
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."
|
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"
|
sharedEditNoticeConfirm = "Got it"
|
||||||
sharedEditNoticeTitle = "Read-only server copy"
|
sharedEditNoticeTitle = "Read-only server copy"
|
||||||
sharedWithYou = "Shared with you"
|
sharedWithYou = "Shared with you"
|
||||||
shareSelected = "Share Selected"
|
shareSelected = "Share Files"
|
||||||
sharing = "Sharing"
|
sharing = "Sharing"
|
||||||
showAll = "Show All"
|
showAll = "Show All"
|
||||||
showHistory = "Show History"
|
showHistory = "Show History"
|
||||||
@@ -3703,24 +3713,59 @@ supportMessage = "Powered by browser database storage for unlimited capacity"
|
|||||||
synced = "Synced"
|
synced = "Synced"
|
||||||
title = "Upload PDF Files"
|
title = "Upload PDF Files"
|
||||||
toolChain = "Tools Applied"
|
toolChain = "Tools Applied"
|
||||||
totalSelected = "Total Selected"
|
totalSelected = "Total Files"
|
||||||
unsupported = "Unsupported"
|
unsupported = "Unsupported"
|
||||||
unzip = "Unzip"
|
unzip = "Unzip"
|
||||||
updateOnServer = "Update on Server"
|
updateOnServer = "Update on Server"
|
||||||
uploadError = "Failed to upload some files."
|
uploadError = "Failed to upload some files."
|
||||||
uploadSelected = "Upload Selected"
|
uploadSelected = "Upload Files"
|
||||||
uploadToServer = "Upload to Server"
|
uploadToServer = "Upload to Server"
|
||||||
|
|
||||||
[files]
|
[files]
|
||||||
addFiles = "Add files"
|
addFiles = "Add files"
|
||||||
created = "Created"
|
created = "Created"
|
||||||
selectFromWorkbench = "Select files from the workbench or "
|
selectFromWorkbench = "Add files to the workbench or "
|
||||||
selectMultipleFromWorkbench = "Select at least {{count}} files from the workbench or "
|
selectMultipleFromWorkbench = "Add at least {{count}} files to the workbench or "
|
||||||
size = "File Size"
|
size = "File Size"
|
||||||
title = "Files"
|
title = "Files"
|
||||||
upload = "Upload"
|
upload = "Upload"
|
||||||
uploadFiles = "Upload Files"
|
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 = "A–Z"
|
||||||
|
|
||||||
|
[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]
|
[fileToPDF]
|
||||||
credit = "This service uses LibreOffice and Unoconv for file conversion."
|
credit = "This service uses LibreOffice and Unoconv for file conversion."
|
||||||
header = "Convert any file to PDF"
|
header = "Convert any file to PDF"
|
||||||
@@ -4592,7 +4637,7 @@ goToFileEditor = "Go to file editor"
|
|||||||
submit = "Merge"
|
submit = "Merge"
|
||||||
tags = "merge,Page operations,Back end,server side"
|
tags = "merge,Page operations,Back end,server side"
|
||||||
title = "Merge"
|
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]
|
[merge.error]
|
||||||
failed = "An error occurred while merging the PDFs."
|
failed = "An error occurred while merging the PDFs."
|
||||||
@@ -4615,7 +4660,7 @@ title = "Remove Digital Signature"
|
|||||||
ascending = "Ascending"
|
ascending = "Ascending"
|
||||||
dateModified = "Date Modified"
|
dateModified = "Date Modified"
|
||||||
descending = "Descending"
|
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"
|
filename = "File Name"
|
||||||
label = "Sort By"
|
label = "Sort By"
|
||||||
sort = "Sort"
|
sort = "Sort"
|
||||||
@@ -4859,7 +4904,7 @@ toolInterface = "This is the <strong>Crop</strong> tool interface. As you can se
|
|||||||
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
|
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
|
||||||
viewSwitcher = "Use these controls to select how you want to view your PDFs."
|
viewSwitcher = "Use these controls to select how you want to view your PDFs."
|
||||||
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
|
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
|
||||||
wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."
|
wrapUp = "You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||||
|
|
||||||
[onboarding.buttons]
|
[onboarding.buttons]
|
||||||
back = "Back"
|
back = "Back"
|
||||||
@@ -4924,7 +4969,6 @@ fileUpload = "Use the <strong>Files</strong> button to upload or pick a recent P
|
|||||||
leftPanel = "The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly."
|
leftPanel = "The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly."
|
||||||
pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages."
|
pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages."
|
||||||
quickAccess = "Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours."
|
quickAccess = "Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours."
|
||||||
rightRail = "The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results."
|
|
||||||
topBar = "The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>."
|
topBar = "The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>."
|
||||||
wrapUp = "That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour."
|
wrapUp = "That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour."
|
||||||
|
|
||||||
@@ -4996,8 +5040,8 @@ text = "Foreground places the overlay on top of the page. Background places it b
|
|||||||
title = "Overlay Position"
|
title = "Overlay Position"
|
||||||
|
|
||||||
[pageEdit]
|
[pageEdit]
|
||||||
deselectAll = "Select None"
|
deselectAll = "None"
|
||||||
selectAll = "Select All"
|
selectAll = "Check All"
|
||||||
|
|
||||||
[pageEditor]
|
[pageEditor]
|
||||||
actualSize = "Actual Size"
|
actualSize = "Actual Size"
|
||||||
@@ -6525,9 +6569,10 @@ title = "High Contrast"
|
|||||||
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
||||||
title = "Invert All Colours"
|
title = "Invert All Colours"
|
||||||
|
|
||||||
[rightRail]
|
[workbenchBar]
|
||||||
annotations = "Annotations"
|
annotations = "Annotations"
|
||||||
applyRedactionsFirst = "Apply redactions first"
|
applyRedactionsFirst = "Apply redactions first"
|
||||||
|
closeAll = "Close All Files"
|
||||||
closePdf = "Close PDF"
|
closePdf = "Close PDF"
|
||||||
closeSelected = "Close Selected Files"
|
closeSelected = "Close Selected Files"
|
||||||
deleteSelected = "Delete Selected Pages"
|
deleteSelected = "Delete Selected Pages"
|
||||||
@@ -6557,6 +6602,7 @@ search = "Search PDF"
|
|||||||
selectAll = "Select All"
|
selectAll = "Select All"
|
||||||
selectByNumber = "Select by Page Numbers"
|
selectByNumber = "Select by Page Numbers"
|
||||||
selectLanguage = "Select language"
|
selectLanguage = "Select language"
|
||||||
|
share = "Share"
|
||||||
toggleAnnotations = "Toggle Annotations Visibility"
|
toggleAnnotations = "Toggle Annotations Visibility"
|
||||||
toggleAttachments = "Toggle Attachments"
|
toggleAttachments = "Toggle Attachments"
|
||||||
toggleBookmarks = "Toggle Bookmarks"
|
toggleBookmarks = "Toggle Bookmarks"
|
||||||
@@ -6564,6 +6610,9 @@ toggleComments = "Comments"
|
|||||||
toggleLayers = "Toggle Layers"
|
toggleLayers = "Toggle Layers"
|
||||||
toggleSidebar = "Toggle Sidebar"
|
toggleSidebar = "Toggle Sidebar"
|
||||||
toggleTheme = "Toggle Theme"
|
toggleTheme = "Toggle Theme"
|
||||||
|
activeFiles = "Active Files"
|
||||||
|
multiTool = "Multi-Tool"
|
||||||
|
viewer = "Viewer"
|
||||||
|
|
||||||
[rotate]
|
[rotate]
|
||||||
rotateLeft = "Rotate Anticlockwise"
|
rotateLeft = "Rotate Anticlockwise"
|
||||||
@@ -6797,7 +6846,7 @@ defaultPdfEditorChecking = "Checking..."
|
|||||||
defaultPdfEditorInactive = "Another application is set as default"
|
defaultPdfEditorInactive = "Another application is set as default"
|
||||||
defaultPdfEditorSet = "Already Default"
|
defaultPdfEditorSet = "Already Default"
|
||||||
defaultStartupView = "Default view on launch"
|
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"
|
defaultToolPickerMode = "Default tool picker mode"
|
||||||
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
|
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
|
||||||
defaultViewerZoom = "Default reader zoom"
|
defaultViewerZoom = "Default reader zoom"
|
||||||
@@ -6807,8 +6856,14 @@ hideUnavailableConversions = "Hide unavailable conversions"
|
|||||||
hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||||
hideUnavailableTools = "Hide unavailable tools"
|
hideUnavailableTools = "Hide unavailable tools"
|
||||||
hideUnavailableToolsDescription = "Remove tools that have been disabled by your server instead of showing them greyed out."
|
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"
|
logout = "Log out"
|
||||||
setAsDefault = "Set as Default"
|
setAsDefault = "Set as Default"
|
||||||
|
theme = "Theme"
|
||||||
|
themeDark = "Dark"
|
||||||
|
themeDescription = "Switch between light and dark mode"
|
||||||
|
themeLight = "Light"
|
||||||
title = "General"
|
title = "General"
|
||||||
user = "User"
|
user = "User"
|
||||||
|
|
||||||
@@ -6983,6 +7038,20 @@ title = "Team"
|
|||||||
enableLoginFirst = "Enable login mode first"
|
enableLoginFirst = "Enable login mode first"
|
||||||
requiresEnterprise = "Requires Enterprise license"
|
requiresEnterprise = "Requires Enterprise license"
|
||||||
|
|
||||||
|
[settings.help]
|
||||||
|
label = "Tours"
|
||||||
|
title = "Help"
|
||||||
|
|
||||||
|
[settings.help.adminTour]
|
||||||
|
description = "Explore team management, system settings, and enterprise features."
|
||||||
|
start = "Start"
|
||||||
|
title = "Admin Tour"
|
||||||
|
|
||||||
|
[settings.help.toolsTour]
|
||||||
|
description = "Walk through uploading files, picking a tool, and reviewing results."
|
||||||
|
start = "Start"
|
||||||
|
title = "Tools Tour"
|
||||||
|
|
||||||
[settings.workspace]
|
[settings.workspace]
|
||||||
people = "People"
|
people = "People"
|
||||||
teams = "Teams"
|
teams = "Teams"
|
||||||
@@ -7580,8 +7649,8 @@ accessLimitedCommenter = "Comment access is coming soon. Ask the owner for edito
|
|||||||
accessLimitedTitle = "Limited access"
|
accessLimitedTitle = "Limited access"
|
||||||
accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download."
|
accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download."
|
||||||
addUser = "Add"
|
addUser = "Add"
|
||||||
bulkDescription = "Create one link to share all selected files with signed-in users."
|
bulkDescription = "Create one link to share all checked files with signed-in users."
|
||||||
bulkTitle = "Share selected files"
|
bulkTitle = "Share checked files"
|
||||||
commenterHint = "Commenting is coming soon."
|
commenterHint = "Commenting is coming soon."
|
||||||
copied = "Link copied to clipboard"
|
copied = "Link copied to clipboard"
|
||||||
copy = "Copy"
|
copy = "Copy"
|
||||||
@@ -7600,7 +7669,7 @@ errorTitle = "Share failed"
|
|||||||
expiredBody = "This share link is invalid or has expired."
|
expiredBody = "This share link is invalid or has expired."
|
||||||
expiredTitle = "Link expired"
|
expiredTitle = "Link expired"
|
||||||
failure = "Unable to generate a share link. Please try again."
|
failure = "Unable to generate a share link. Please try again."
|
||||||
fileCount = "{{count}} files selected"
|
fileCount = "{{count}} files"
|
||||||
fileLabel = "File"
|
fileLabel = "File"
|
||||||
generate = "Generate Link"
|
generate = "Generate Link"
|
||||||
generated = "Share link generated"
|
generated = "Share link generated"
|
||||||
@@ -7656,12 +7725,12 @@ viewed = "Viewed"
|
|||||||
viewsCount = "Views: {{count}}"
|
viewsCount = "Views: {{count}}"
|
||||||
|
|
||||||
[storageUpload]
|
[storageUpload]
|
||||||
bulkDescription = "This uploads the selected files to your server storage."
|
bulkDescription = "This uploads the checked files to your server storage."
|
||||||
bulkTitle = "Upload selected files"
|
bulkTitle = "Upload checked files"
|
||||||
description = "This uploads the current file to server storage for your own access."
|
description = "This uploads the current file to server storage for your own access."
|
||||||
errorTitle = "Upload failed"
|
errorTitle = "Upload failed"
|
||||||
failure = "Upload failed. Please check your login and storage settings."
|
failure = "Upload failed. Please check your login and storage settings."
|
||||||
fileCount = "{{count}} files selected"
|
fileCount = "{{count}} files"
|
||||||
fileLabel = "File"
|
fileLabel = "File"
|
||||||
hint = "Public links and access modes are controlled by your server settings."
|
hint = "Public links and access modes are controlled by your server settings."
|
||||||
more = " +{{count}} more"
|
more = " +{{count}} more"
|
||||||
@@ -7821,19 +7890,26 @@ settings = "Settings"
|
|||||||
[tool]
|
[tool]
|
||||||
endpointUnavailable = "This tool is unavailable on your server."
|
endpointUnavailable = "This tool is unavailable on your server."
|
||||||
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
|
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."
|
invalidParams = "Fill in the required settings."
|
||||||
noFiles = "Add a file to get started."
|
noFiles = "Add a file to get started."
|
||||||
scopeFiles = "files"
|
scopeFiles = "files"
|
||||||
scopeThisFile = "this file"
|
scopeThisFile = "this file"
|
||||||
selectFilesHint = "Select files in Active Files to run this tool"
|
selectFilesHint = "Select files in Active Files to run this tool"
|
||||||
singleFileScope = "Only applying to: {{fileName}}"
|
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]
|
[toolPanel]
|
||||||
|
allTools = "All tools"
|
||||||
alpha = "Alpha"
|
alpha = "Alpha"
|
||||||
|
backToTools = "Back to tools"
|
||||||
|
collapse = "Collapse panel"
|
||||||
comingSoon = "Coming soon:"
|
comingSoon = "Coming soon:"
|
||||||
|
expand = "Expand panel"
|
||||||
placeholder = "Choose a tool to get started"
|
placeholder = "Choose a tool to get started"
|
||||||
premiumFeature = "Premium feature:"
|
premiumFeature = "Premium feature:"
|
||||||
|
search = "Search tools"
|
||||||
|
|
||||||
[toolPanel.fullscreen]
|
[toolPanel.fullscreen]
|
||||||
comingSoon = "Coming soon:"
|
comingSoon = "Coming soon:"
|
||||||
@@ -8384,6 +8460,7 @@ 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."
|
description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo."
|
||||||
title = "Upload Signature Image"
|
title = "Upload Signature Image"
|
||||||
|
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
title = "Workspace"
|
title = "Workspace"
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
.h-screen,
|
.h-screen {
|
||||||
.right-rail {
|
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
AppConfigRetryOptions,
|
AppConfigRetryOptions,
|
||||||
useAppConfig,
|
useAppConfig,
|
||||||
} from "@app/contexts/AppConfigContext";
|
} from "@app/contexts/AppConfigContext";
|
||||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
import { WorkbenchBarProvider } from "@app/contexts/WorkbenchBarContext";
|
||||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||||
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
|
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
|
||||||
@@ -139,13 +139,13 @@ export function AppProviders({
|
|||||||
<RedactionProvider>
|
<RedactionProvider>
|
||||||
<FormFillProvider>
|
<FormFillProvider>
|
||||||
<AnnotationProvider>
|
<AnnotationProvider>
|
||||||
<RightRailProvider>
|
<WorkbenchBarProvider>
|
||||||
<TourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
<AdminTourOrchestrationProvider>
|
<AdminTourOrchestrationProvider>
|
||||||
{children}
|
{children}
|
||||||
</AdminTourOrchestrationProvider>
|
</AdminTourOrchestrationProvider>
|
||||||
</TourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</WorkbenchBarProvider>
|
||||||
</AnnotationProvider>
|
</AnnotationProvider>
|
||||||
</FormFillProvider>
|
</FormFillProvider>
|
||||||
</RedactionProvider>
|
</RedactionProvider>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect, useMemo } from "react";
|
|||||||
import { Modal } from "@mantine/core";
|
import { Modal } from "@mantine/core";
|
||||||
import { Dropzone } from "@mantine/dropzone";
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
|
import type { FileId } from "@app/types/file";
|
||||||
import { useFileManager } from "@app/hooks/useFileManager";
|
import { useFileManager } from "@app/hooks/useFileManager";
|
||||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "@app/services/googleDrivePickerService";
|
} from "@app/services/googleDrivePickerService";
|
||||||
import { loadScript } from "@app/utils/scriptLoader";
|
import { loadScript } from "@app/utils/scriptLoader";
|
||||||
import { useAllFiles } from "@app/contexts/FileContext";
|
import { useAllFiles } from "@app/contexts/FileContext";
|
||||||
|
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
selectedTool?: Tool | null;
|
selectedTool?: Tool | null;
|
||||||
@@ -28,6 +30,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
closeFilesModal,
|
closeFilesModal,
|
||||||
onFileUpload,
|
onFileUpload,
|
||||||
onRecentFileSelect,
|
onRecentFileSelect,
|
||||||
|
maxSelectable,
|
||||||
} = useFilesModalContext();
|
} = useFilesModalContext();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||||
@@ -35,6 +38,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||||
|
const { actions: fileActions } = useFileActions();
|
||||||
|
|
||||||
// Get active file IDs from FileContext to show which files are already loaded
|
// Get active file IDs from FileContext to show which files are already loaded
|
||||||
const { fileIds: activeFileIds } = useAllFiles();
|
const { fileIds: activeFileIds } = useAllFiles();
|
||||||
@@ -83,11 +87,24 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
|
|
||||||
const handleRemoveFileByIndex = useCallback(
|
const handleRemoveFileByIndex = useCallback(
|
||||||
async (index: number) => {
|
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(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||||
checkMobile();
|
checkMobile();
|
||||||
@@ -211,7 +228,9 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
onClose={closeFilesModal}
|
onClose={closeFilesModal}
|
||||||
isFileSupported={isFileSupported}
|
isFileSupported={isFileSupported}
|
||||||
isOpen={isFilesModalOpen}
|
isOpen={isFilesModalOpen}
|
||||||
|
maxSelectable={maxSelectable}
|
||||||
onFileRemove={handleRemoveFileByIndex}
|
onFileRemove={handleRemoveFileByIndex}
|
||||||
|
onBulkRemove={handleBulkRemove}
|
||||||
modalHeight={modalHeight}
|
modalHeight={modalHeight}
|
||||||
refreshRecentFiles={refreshRecentFiles}
|
refreshRecentFiles={refreshRecentFiles}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Button, Group } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
|
||||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||||
@@ -69,7 +68,7 @@ const AddFileCard = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<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}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={t("fileEditor.addFiles", "Add files")}
|
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 */}
|
{/* Main content area */}
|
||||||
<div className={styles.addFileContent}>
|
<div className={styles.addFileContent}>
|
||||||
{/* Stirling PDF Branding */}
|
{/* Stirling PDF Branding */}
|
||||||
|
|||||||
@@ -328,42 +328,33 @@
|
|||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
.addFileCard {
|
.addFileCard {
|
||||||
|
width: 260px;
|
||||||
|
max-width: 260px;
|
||||||
|
height: calc(310px - 0.5rem);
|
||||||
|
margin: 0.5rem auto 0;
|
||||||
background: var(--file-card-bg);
|
background: var(--file-card-bg);
|
||||||
border: 2px dashed var(--border-default);
|
border: 1.5px solid var(--border-default);
|
||||||
border-radius: 0.0625rem;
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.18s ease;
|
transition:
|
||||||
max-width: 100%;
|
box-shadow 0.18s ease,
|
||||||
max-height: 100%;
|
transform 0.2s ease,
|
||||||
|
border-color 0.18s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-left: 0.5rem;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.addFileCard:hover {
|
.addFileCard:hover {
|
||||||
opacity: 1;
|
box-shadow: var(--shadow-lg);
|
||||||
border-color: var(--color-blue-500);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-md);
|
border-color: var(--accent-interactive);
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.addFileCard:focus {
|
.addFileCard:focus-visible {
|
||||||
outline: 2px solid var(--color-blue-500);
|
outline: 2px solid var(--accent-interactive);
|
||||||
outline-offset: 2px;
|
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 {
|
.addFileContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
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 { 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 { Dropzone } from "@mantine/dropzone";
|
||||||
import {
|
import {
|
||||||
useFileSelection,
|
useFileSelection,
|
||||||
useFileState,
|
useFileState,
|
||||||
useFileManagement,
|
useFileManagement,
|
||||||
useFileActions,
|
useFileActions,
|
||||||
useFileContext,
|
|
||||||
} from "@app/contexts/FileContext";
|
} from "@app/contexts/FileContext";
|
||||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
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 { FileId, StirlingFile } from "@app/types/fileContext";
|
||||||
import { alert } from "@app/components/toast";
|
import { alert } from "@app/components/toast";
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
import { downloadFile } from "@app/services/downloadService";
|
||||||
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
|
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
@@ -46,17 +44,13 @@ const FileEditor = ({
|
|||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||||
const { actions: fileActions } = useFileActions();
|
const { actions: fileActions } = useFileActions();
|
||||||
const { actions: fileContextActions } = useFileContext();
|
const { selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||||
const { clearAllFileErrors } = fileContextActions;
|
|
||||||
|
|
||||||
// Extract needed values from state (memoized to prevent infinite loops)
|
// Extract needed values from state (memoized to prevent infinite loops)
|
||||||
const activeStirlingFileStubs = useMemo(
|
const activeStirlingFileStubs = useMemo(
|
||||||
() => selectors.getStirlingFileStubs(),
|
() => selectors.getStirlingFileStubs(),
|
||||||
[state.files.byId, state.files.ids],
|
[state.files.byId, state.files.ids],
|
||||||
);
|
);
|
||||||
const selectedFileIds = state.ui.selectedFileIds;
|
|
||||||
const totalItems = state.files.ids.length;
|
|
||||||
const selectedCount = selectedFileIds.length;
|
|
||||||
|
|
||||||
// Get navigation actions
|
// Get navigation actions
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
@@ -64,9 +58,6 @@ const FileEditor = ({
|
|||||||
// Get viewer context for setting active file index and ID
|
// Get viewer context for setting active file index and ID
|
||||||
const { setActiveFileIndex, setActiveFileId } = useViewer();
|
const { setActiveFileIndex, setActiveFileId } = useViewer();
|
||||||
|
|
||||||
// Get file selection context
|
|
||||||
const { setSelectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const [_status, _setStatus] = useState<string | null>(null);
|
const [_status, _setStatus] = useState<string | null>(null);
|
||||||
const [_error, _setError] = useState<string | null>(null);
|
const [_error, _setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -93,7 +84,6 @@ const FileEditor = ({
|
|||||||
expandable: true,
|
expandable: true,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
|
||||||
|
|
||||||
// Current tool (for enforcing maxFiles limits)
|
// Current tool (for enforcing maxFiles limits)
|
||||||
const { selectedTool } = useToolWorkflow();
|
const { selectedTool } = useToolWorkflow();
|
||||||
@@ -104,65 +94,7 @@ const FileEditor = ({
|
|||||||
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||||
}, [selectedTool?.maxFiles, toolMode]);
|
}, [selectedTool?.maxFiles, toolMode]);
|
||||||
|
|
||||||
// Enable selection mode automatically in tool mode
|
|
||||||
useEffect(() => {
|
|
||||||
if (toolMode) {
|
|
||||||
setSelectionMode(true);
|
|
||||||
}
|
|
||||||
}, [toolMode]);
|
|
||||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
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
|
// Process uploaded files using context
|
||||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||||
@@ -199,56 +131,6 @@ const FileEditor = ({
|
|||||||
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
|
[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
|
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
|
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
|
||||||
@@ -495,16 +377,7 @@ const FileEditor = ({
|
|||||||
<Box p="md">
|
<Box p="md">
|
||||||
{activeStirlingFileStubs.length === 0 ? (
|
{activeStirlingFileStubs.length === 0 ? (
|
||||||
<Center h="60vh">
|
<Center h="60vh">
|
||||||
<Stack align="center" gap="md">
|
<AddFileCard onFileSelect={handleFileUpload} />
|
||||||
<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>
|
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -531,12 +404,8 @@ const FileEditor = ({
|
|||||||
file={record}
|
file={record}
|
||||||
index={index}
|
index={index}
|
||||||
totalFiles={activeStirlingFileStubs.length}
|
totalFiles={activeStirlingFileStubs.length}
|
||||||
selectedFiles={localSelectedIds}
|
|
||||||
selectionMode={selectionMode}
|
|
||||||
onToggleFile={toggleFile}
|
|
||||||
onCloseFile={handleCloseFile}
|
onCloseFile={handleCloseFile}
|
||||||
onViewFile={handleViewFile}
|
onViewFile={handleViewFile}
|
||||||
_onSetStatus={showStatus}
|
|
||||||
onReorderFiles={handleReorderFiles}
|
onReorderFiles={handleReorderFiles}
|
||||||
onDownloadFile={handleDownloadFile}
|
onDownloadFile={handleDownloadFile}
|
||||||
onUnzipFile={handleUnzipFile}
|
onUnzipFile={handleUnzipFile}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
|
|
||||||
|
interface FileEditorStatusDotProps {
|
||||||
|
file: StirlingFileStub;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileEditorStatusDot(_props: FileEditorStatusDotProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbBadgesRight {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 React, { useState, useCallback, useRef, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
ActionIcon,
|
|
||||||
CheckboxIndicator,
|
|
||||||
Tooltip,
|
|
||||||
Modal,
|
Modal,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
Loader,
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import { alert } from "@app/components/toast";
|
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 CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
import LinkIcon from "@mui/icons-material/Link";
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
|
||||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
import {
|
import {
|
||||||
@@ -31,11 +28,10 @@ import {
|
|||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { zipFileService } from "@app/services/zipFileService";
|
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 { useFileContext } from "@app/contexts/FileContext";
|
||||||
import { useFileState } from "@app/contexts/file/fileHooks";
|
import { useFileState } from "@app/contexts/file/fileHooks";
|
||||||
import { FileId } from "@app/types/file";
|
import { FileId } from "@app/types/file";
|
||||||
import { formatFileSize } from "@app/utils/fileUtils";
|
|
||||||
import ToolChain from "@app/components/shared/ToolChain";
|
import ToolChain from "@app/components/shared/ToolChain";
|
||||||
import HoverActionMenu, {
|
import HoverActionMenu, {
|
||||||
HoverAction,
|
HoverAction,
|
||||||
@@ -45,18 +41,17 @@ import { PrivateContent } from "@app/components/shared/PrivateContent";
|
|||||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName";
|
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
|
||||||
|
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||||
|
import { truncateCenter } from "@app/utils/textUtils";
|
||||||
|
import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
|
||||||
|
|
||||||
interface FileEditorThumbnailProps {
|
interface FileEditorThumbnailProps {
|
||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
index: number;
|
index: number;
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
selectedFiles: FileId[];
|
|
||||||
selectionMode: boolean;
|
|
||||||
onToggleFile: (fileId: FileId) => void;
|
|
||||||
onCloseFile: (fileId: FileId) => void;
|
onCloseFile: (fileId: FileId) => void;
|
||||||
onViewFile: (fileId: FileId) => void;
|
onViewFile: (fileId: FileId) => void;
|
||||||
_onSetStatus: (status: string) => void;
|
|
||||||
onReorderFiles?: (
|
onReorderFiles?: (
|
||||||
sourceFileId: FileId,
|
sourceFileId: FileId,
|
||||||
targetFileId: FileId,
|
targetFileId: FileId,
|
||||||
@@ -70,12 +65,8 @@ interface FileEditorThumbnailProps {
|
|||||||
|
|
||||||
const FileEditorThumbnail = ({
|
const FileEditorThumbnail = ({
|
||||||
file,
|
file,
|
||||||
index,
|
|
||||||
selectedFiles,
|
|
||||||
onToggleFile,
|
|
||||||
onCloseFile,
|
onCloseFile,
|
||||||
onViewFile,
|
onViewFile,
|
||||||
_onSetStatus,
|
|
||||||
onReorderFiles,
|
onReorderFiles,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
onUnzipFile,
|
onUnzipFile,
|
||||||
@@ -95,41 +86,41 @@ const FileEditorThumbnail = ({
|
|||||||
openEncryptedUnlockPrompt,
|
openEncryptedUnlockPrompt,
|
||||||
} = useFileContext();
|
} = useFileContext();
|
||||||
const { state, selectors } = useFileState();
|
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 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(
|
||||||
const actualFile = useMemo(() => {
|
() => activeFiles.find((f) => f.fileId === file.id),
|
||||||
return activeFiles.find((f) => f.fileId === file.id);
|
[activeFiles, file.id],
|
||||||
}, [activeFiles, file.id]);
|
);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
// Check if this is a ZIP file
|
|
||||||
const isZipFile = zipFileService.isZipFileStub(file);
|
const isZipFile = zipFileService.isZipFileStub(file);
|
||||||
|
|
||||||
|
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||||
const pageCount = file.processedFile?.totalPages || 0;
|
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);
|
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
// ---- Selection ----
|
|
||||||
const isSelected = selectedFiles.includes(file.id);
|
|
||||||
|
|
||||||
// ---- Meta formatting ----
|
|
||||||
const prettySize = useMemo(() => {
|
|
||||||
return formatFileSize(file.size);
|
|
||||||
}, [file.size]);
|
|
||||||
|
|
||||||
const extUpper = useMemo(() => {
|
const extUpper = useMemo(() => {
|
||||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
||||||
@@ -143,6 +134,7 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
const isCBZ = extLower === "cbz";
|
const isCBZ = extLower === "cbz";
|
||||||
const isCBR = extLower === "cbr";
|
const isCBR = extLower === "cbr";
|
||||||
|
|
||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled =
|
const sharingEnabled =
|
||||||
uploadEnabled && config?.storageSharingEnabled === true;
|
uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
@@ -178,7 +170,13 @@ const FileEditorThumbnail = ({
|
|||||||
}).format(d);
|
}).format(d);
|
||||||
}, [file.lastModified]);
|
}, [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(
|
const fileElementRef = useCallback(
|
||||||
(element: HTMLDivElement | null) => {
|
(element: HTMLDivElement | null) => {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
@@ -191,14 +189,10 @@ const FileEditorThumbnail = ({
|
|||||||
type: "file",
|
type: "file",
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
selectedFiles: [file.id], // Always drag only this file, ignore selection state
|
selectedFiles: [file.id],
|
||||||
}),
|
}),
|
||||||
onDragStart: () => {
|
onDragStart: () => setIsDragging(true),
|
||||||
setIsDragging(true);
|
onDrop: () => setIsDragging(false),
|
||||||
},
|
|
||||||
onDrop: () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dropCleanup = dropTargetForElements({
|
const dropCleanup = dropTargetForElements({
|
||||||
@@ -211,10 +205,7 @@ const FileEditorThumbnail = ({
|
|||||||
const sourceData = source.data;
|
const sourceData = source.data;
|
||||||
return sourceData.type === "file" && sourceData.fileId !== file.id;
|
return sourceData.type === "file" && sourceData.fileId !== file.id;
|
||||||
},
|
},
|
||||||
onDragEnter: () => setIsDragOver(true),
|
|
||||||
onDragLeave: () => setIsDragOver(false),
|
|
||||||
onDrop: ({ source }) => {
|
onDrop: ({ source }) => {
|
||||||
setIsDragOver(false);
|
|
||||||
const sourceData = source.data;
|
const sourceData = source.data;
|
||||||
if (sourceData.type === "file" && onReorderFiles) {
|
if (sourceData.type === "file" && onReorderFiles) {
|
||||||
const sourceFileId = sourceData.fileId as FileId;
|
const sourceFileId = sourceData.fileId as FileId;
|
||||||
@@ -229,13 +220,14 @@ const FileEditorThumbnail = ({
|
|||||||
dropCleanup();
|
dropCleanup();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[file.id, file.name, selectedFiles, onReorderFiles],
|
[file.id, file.name, onReorderFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle close with confirmation
|
const handleCloseWithConfirmation = useCallback(
|
||||||
const handleCloseWithConfirmation = useCallback(() => {
|
() => setShowCloseModal(true),
|
||||||
setShowCloseModal(true);
|
[],
|
||||||
}, []);
|
);
|
||||||
|
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
|
||||||
|
|
||||||
const handleConfirmClose = useCallback(() => {
|
const handleConfirmClose = useCallback(() => {
|
||||||
onCloseFile(file.id);
|
onCloseFile(file.id);
|
||||||
@@ -278,7 +270,6 @@ const FileEditorThumbnail = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Then close
|
|
||||||
onCloseFile(file.id);
|
onCloseFile(file.id);
|
||||||
alert({
|
alert({
|
||||||
alertType: "success",
|
alertType: "success",
|
||||||
@@ -296,11 +287,6 @@ const FileEditorThumbnail = ({
|
|||||||
fileActions,
|
fileActions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCancelClose = useCallback(() => {
|
|
||||||
setShowCloseModal(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Build hover menu actions
|
|
||||||
const hoverActions = useMemo<HoverAction[]>(
|
const hoverActions = useMemo<HoverAction[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -312,6 +298,36 @@ const FileEditorThumbnail = ({
|
|||||||
onViewFile(file.id);
|
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)"),
|
||||||
|
dataTour: "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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "download",
|
id: "download",
|
||||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||||
@@ -321,36 +337,32 @@ const FileEditorThumbnail = ({
|
|||||||
onDownloadFile(file.id);
|
onDownloadFile(file.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(canUpload || canShare
|
...(canUpload
|
||||||
? [
|
? [
|
||||||
...(canUpload
|
{
|
||||||
? [
|
id: "upload",
|
||||||
{
|
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||||
id: "upload",
|
label: isUploaded
|
||||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
? t("fileManager.updateOnServer", "Update on Server")
|
||||||
label: isUploaded
|
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||||
? t("fileManager.updateOnServer", "Update on Server")
|
onClick: (e: React.MouseEvent) => {
|
||||||
: t("fileManager.uploadToServer", "Upload to Server"),
|
e.stopPropagation();
|
||||||
onClick: (e: React.MouseEvent) => {
|
setShowUploadModal(true);
|
||||||
e.stopPropagation();
|
},
|
||||||
setShowUploadModal(true);
|
},
|
||||||
},
|
]
|
||||||
},
|
: []),
|
||||||
]
|
...(canShare
|
||||||
: []),
|
? [
|
||||||
...(canShare
|
{
|
||||||
? [
|
id: "share",
|
||||||
{
|
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||||
id: "share",
|
label: t("fileManager.share", "Share"),
|
||||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
onClick: (e: React.MouseEvent) => {
|
||||||
label: t("fileManager.share", "Share"),
|
e.stopPropagation();
|
||||||
onClick: (e: React.MouseEvent) => {
|
setShowShareModal(true);
|
||||||
e.stopPropagation();
|
},
|
||||||
setShowShareModal(true);
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
@@ -389,7 +401,10 @@ const FileEditorThumbnail = ({
|
|||||||
isZipFile,
|
isZipFile,
|
||||||
isCBZ,
|
isCBZ,
|
||||||
isCBR,
|
isCBR,
|
||||||
|
isPinned,
|
||||||
|
actualFile,
|
||||||
terminology,
|
terminology,
|
||||||
|
DownloadOutlinedIcon,
|
||||||
onViewFile,
|
onViewFile,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
onUnzipFile,
|
onUnzipFile,
|
||||||
@@ -397,13 +412,13 @@ const FileEditorThumbnail = ({
|
|||||||
canUpload,
|
canUpload,
|
||||||
canShare,
|
canShare,
|
||||||
isUploaded,
|
isUploaded,
|
||||||
|
pinFile,
|
||||||
|
unpinFile,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Card interactions ----
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (!isSupported) return;
|
if (!isSupported) return;
|
||||||
// Clear error state if file has an error (click to clear error)
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
try {
|
try {
|
||||||
fileActions.clearFileError(file.id);
|
fileActions.clearFileError(file.id);
|
||||||
@@ -415,7 +430,6 @@ const FileEditorThumbnail = ({
|
|||||||
sharedEditNoticeShownRef.current = true;
|
sharedEditNoticeShownRef.current = true;
|
||||||
setShowSharedEditNotice(true);
|
setShowSharedEditNotice(true);
|
||||||
}
|
}
|
||||||
onToggleFile(file.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardDoubleClick = () => {
|
const handleCardDoubleClick = () => {
|
||||||
@@ -423,12 +437,9 @@ const FileEditorThumbnail = ({
|
|||||||
onViewFile(file.id);
|
onViewFile(file.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Style helpers ----
|
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
|
||||||
const getHeaderClassName = () => {
|
.filter(Boolean)
|
||||||
if (hasError) return styles.headerError;
|
.join(" - ");
|
||||||
if (!isSupported) return styles.headerUnsupported;
|
|
||||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -436,268 +447,130 @@ const FileEditorThumbnail = ({
|
|||||||
data-file-id={file.id}
|
data-file-id={file.id}
|
||||||
data-testid="file-thumbnail"
|
data-testid="file-thumbnail"
|
||||||
data-tour="file-card-checkbox"
|
data-tour="file-card-checkbox"
|
||||||
data-selected={isSelected}
|
|
||||||
data-supported={isSupported}
|
data-supported={isSupported}
|
||||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
className={`${styles.card} select-none`}
|
||||||
style={
|
style={{ opacity: isDragging ? 0.9 : 1 }}
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-selected={isSelected}
|
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
onMouseEnter={() => setShowHoverMenu(true)}
|
|
||||||
onMouseLeave={() => setShowHoverMenu(false)}
|
|
||||||
onDoubleClick={handleCardDoubleClick}
|
onDoubleClick={handleCardDoubleClick}
|
||||||
>
|
>
|
||||||
{/* Header bar */}
|
<div className={styles.thumbInner}>
|
||||||
<div
|
{/* Thumbnail area */}
|
||||||
className={`${styles.header} ${getHeaderClassName()}`}
|
<div className={styles.thumbWrap}>
|
||||||
data-has-error={hasError}
|
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
|
||||||
>
|
<div className={styles.thumbUnit}>
|
||||||
{/* Logo/checkbox area */}
|
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
|
||||||
<div className={styles.logoMark}>
|
<div className={styles.toolChainBar}>
|
||||||
{hasError ? (
|
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||||
<div className={styles.errorPill}>
|
<ToolChain
|
||||||
<span>{t("error._value", "Error")}</span>
|
toolChain={file.toolHistory}
|
||||||
</div>
|
displayStyle="text"
|
||||||
) : isSupported ? (
|
size="xs"
|
||||||
<CheckboxIndicator
|
maxWidth="100%"
|
||||||
checked={isSelected}
|
color="var(--mantine-color-gray-7)"
|
||||||
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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.thumbContainer}
|
||||||
|
data-supported={isSupported}
|
||||||
|
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<ActionIcon
|
{/* Error overlay */}
|
||||||
aria-label={t(
|
{hasError && (
|
||||||
"encryptedPdfUnlock.unlockPrompt",
|
<div className={styles.errorOverlay}>
|
||||||
"Unlock PDF to continue",
|
<span className={styles.errorPill}>
|
||||||
)}
|
{t("error._value", "Error")}
|
||||||
variant="subtle"
|
</span>
|
||||||
className={styles.headerIconButton}
|
</div>
|
||||||
onClick={(e) => {
|
)}
|
||||||
e.stopPropagation();
|
|
||||||
openEncryptedUnlockPrompt(file.id);
|
{/* Thumbnail image or loading state */}
|
||||||
}}
|
<DocumentThumbnail
|
||||||
>
|
file={file}
|
||||||
<LockOpenIcon fontSize="small" />
|
thumbnail={displayThumbnail || undefined}
|
||||||
</ActionIcon>
|
isEncrypted={isEncrypted}
|
||||||
</Tooltip>
|
isLoading={
|
||||||
)}
|
!isEncrypted &&
|
||||||
{/* Pin/Unpin icon */}
|
!displayThumbnail &&
|
||||||
<Tooltip
|
(isThumbGenerating ||
|
||||||
label={
|
file.type?.startsWith("application/pdf") ||
|
||||||
isPinned
|
file.type?.startsWith("image/"))
|
||||||
? 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
iconSize="6rem"
|
||||||
>
|
imgClassName={styles.thumbImage}
|
||||||
{isPinned ? (
|
onImageError={(e) => {
|
||||||
<PushPinIcon fontSize="small" />
|
e.currentTarget.style.display = "none";
|
||||||
) : (
|
|
||||||
<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}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileEditorFileName file={file} />
|
|
||||||
</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",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
|
||||||
) : file.type?.startsWith("application/pdf") ? (
|
{/* Badges — top-left: version, pin, ownership, encrypted */}
|
||||||
<Stack
|
<div className={styles.thumbBadges}>
|
||||||
align="center"
|
<span className={styles.versionBadgeThumb}>
|
||||||
justify="center"
|
v{file.versionNumber}
|
||||||
gap="xs"
|
</span>
|
||||||
style={{ height: "100%" }}
|
{isPinned && (
|
||||||
>
|
<span className={styles.pinnedBadge}>
|
||||||
<Loader size="sm" />
|
<PushPinIcon style={{ fontSize: 12 }} />
|
||||||
<Text size="xs" c="dimmed">
|
</span>
|
||||||
Loading thumbnail...
|
)}
|
||||||
</Text>
|
{isSharedFile && !isOwnedOrLocal && (
|
||||||
</Stack>
|
<span className={styles.ownershipBadge}>
|
||||||
) : null}
|
{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>
|
||||||
|
|
||||||
|
<FileEditorStatusDot file={file} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* end thumbUnit */}
|
||||||
|
|
||||||
|
{/* Drag handle */}
|
||||||
|
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||||
|
<DragIndicatorIcon fontSize="small" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
|
||||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
<HoverActionMenu
|
||||||
<DragIndicatorIcon fontSize="small" />
|
show={isMobile}
|
||||||
</span>
|
actions={hoverActions}
|
||||||
|
position="outside"
|
||||||
{/* Tool chain display at bottom */}
|
visibility="cssHover"
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Menu */}
|
{/* File name + meta */}
|
||||||
<HoverActionMenu
|
<div className={styles.fileText}>
|
||||||
show={showHoverMenu || isMobile}
|
<p className={styles.fileName}>
|
||||||
actions={hoverActions}
|
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||||
position="outside"
|
</p>
|
||||||
/>
|
<p className={styles.fileMeta}>{metaLine}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Close Confirmation Modal */}
|
{/* Close Confirmation Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -714,7 +587,7 @@ const FileEditorThumbnail = ({
|
|||||||
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
|
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
{file.name}
|
<PrivateContent>{file.name}</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="light" onClick={handleCancelClose}>
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
@@ -741,7 +614,7 @@ const FileEditorThumbnail = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
{file.name}
|
<PrivateContent>{file.name}</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="light" onClick={handleCancelClose}>
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
@@ -759,6 +632,8 @@ const FileEditorThumbnail = ({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Shared edit notice modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={showSharedEditNotice}
|
opened={showSharedEditNotice}
|
||||||
onClose={() => setShowSharedEditNotice(false)}
|
onClose={() => setShowSharedEditNotice(false)}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
useRightRailButtons,
|
|
||||||
RightRailButtonWithAction,
|
|
||||||
} from "@app/hooks/useRightRailButtons";
|
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
|
||||||
|
|
||||||
interface FileEditorRightRailButtonsParams {
|
|
||||||
totalItems: number;
|
|
||||||
selectedCount: number;
|
|
||||||
onSelectAll: () => void;
|
|
||||||
onDeselectAll: () => void;
|
|
||||||
onCloseSelected: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFileEditorRightRailButtons({
|
|
||||||
totalItems,
|
|
||||||
selectedCount,
|
|
||||||
onSelectAll,
|
|
||||||
onDeselectAll,
|
|
||||||
onCloseSelected,
|
|
||||||
}: FileEditorRightRailButtonsParams) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
const buttons = useMemo<RightRailButtonWithAction[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: "file-select-all",
|
|
||||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
|
||||||
tooltip: t("rightRail.selectAll", "Select All"),
|
|
||||||
ariaLabel:
|
|
||||||
typeof t === "function"
|
|
||||||
? t("rightRail.selectAll", "Select All")
|
|
||||||
: "Select All",
|
|
||||||
section: "top" as const,
|
|
||||||
order: 10,
|
|
||||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
|
||||||
visible: totalItems > 0,
|
|
||||||
onClick: onSelectAll,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "file-deselect-all",
|
|
||||||
icon: (
|
|
||||||
<LocalIcon
|
|
||||||
icon="crop-square-outline"
|
|
||||||
width="1.5rem"
|
|
||||||
height="1.5rem"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
|
||||||
ariaLabel:
|
|
||||||
typeof t === "function"
|
|
||||||
? t("rightRail.deselectAll", "Deselect All")
|
|
||||||
: "Deselect All",
|
|
||||||
section: "top" as const,
|
|
||||||
order: 20,
|
|
||||||
disabled: selectedCount === 0,
|
|
||||||
visible: totalItems > 0,
|
|
||||||
onClick: onDeselectAll,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "file-close-selected",
|
|
||||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
|
||||||
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
|
||||||
ariaLabel:
|
|
||||||
typeof t === "function"
|
|
||||||
? t("rightRail.closeSelected", "Close Selected Files")
|
|
||||||
: "Close Selected Files",
|
|
||||||
section: "top" as const,
|
|
||||||
order: 30,
|
|
||||||
disabled: selectedCount === 0,
|
|
||||||
visible: totalItems > 0,
|
|
||||||
onClick: onCloseSelected,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
t,
|
|
||||||
i18n.language,
|
|
||||||
totalItems,
|
|
||||||
selectedCount,
|
|
||||||
onSelectAll,
|
|
||||||
onDeselectAll,
|
|
||||||
onCloseSelected,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
useRightRailButtons(buttons);
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,7 @@ interface CompactFileDetailsProps {
|
|||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onOpenFiles: () => void;
|
onOpenFiles: () => void;
|
||||||
|
canCloseAll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||||
@@ -30,6 +31,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
onPrevious,
|
onPrevious,
|
||||||
onNext,
|
onNext,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
|
canCloseAll = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasSelection = selectedFiles.length > 0;
|
const hasSelection = selectedFiles.length > 0;
|
||||||
@@ -94,7 +96,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} truncate>
|
<Text size="sm" fw={500} truncate>
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
{currentFile ? currentFile.name : "No file selected"}
|
{currentFile ? currentFile.name : "No file loaded"}
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -149,18 +151,21 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onOpenFiles}
|
onClick={onOpenFiles}
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection && !canCloseAll}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: hasSelection
|
backgroundColor:
|
||||||
? "var(--btn-open-file)"
|
hasSelection || canCloseAll
|
||||||
: "var(--mantine-color-gray-4)",
|
? "var(--btn-open-file)"
|
||||||
|
: "var(--mantine-color-gray-4)",
|
||||||
color: "white",
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedFiles.length > 1
|
{canCloseAll
|
||||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
? t("fileManager.closeAllFiles", "Close all files")
|
||||||
: t("fileManager.openFile", "Open File")}
|
: selectedFiles.length > 1
|
||||||
|
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||||
|
: t("fileManager.openFile", "Open File")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ interface FileDetailsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } =
|
||||||
|
useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
@@ -21,6 +22,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
const currentFile =
|
const currentFile =
|
||||||
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||||
const hasSelection = selectedFiles.length > 0;
|
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
|
// Use IndexedDB hook for the current file
|
||||||
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
||||||
@@ -71,6 +75,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
onPrevious={handlePrevious}
|
onPrevious={handlePrevious}
|
||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
onOpenFiles={onOpenFiles}
|
onOpenFiles={onOpenFiles}
|
||||||
|
canCloseAll={canCloseAll}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,18 +109,21 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
onClick={onOpenFiles}
|
onClick={onOpenFiles}
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection && !canCloseAll}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: hasSelection
|
backgroundColor:
|
||||||
? "var(--btn-open-file)"
|
hasSelection || canCloseAll
|
||||||
: "var(--mantine-color-gray-4)",
|
? "var(--btn-open-file)"
|
||||||
|
: "var(--mantine-color-gray-4)",
|
||||||
color: "white",
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedFiles.length > 1
|
{canCloseAll
|
||||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
? t("fileManager.closeAllFiles", "Close all files")
|
||||||
: t("fileManager.openFile", "Open File")}
|
: selectedFiles.length > 1
|
||||||
|
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||||
|
: t("fileManager.openFile", "Open File")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ interface FileHistoryGroupProps {
|
|||||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||||
isFileSupported: (fileName: string) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||||
@@ -21,7 +20,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
|||||||
onDownloadSingle,
|
onDownloadSingle,
|
||||||
onFileDoubleClick,
|
onFileDoubleClick,
|
||||||
onHistoryFileRemove,
|
onHistoryFileRemove,
|
||||||
isFileSupported,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -50,7 +48,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
|||||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||||
file={historyFile}
|
file={historyFile}
|
||||||
isSelected={false} // History files are not selectable
|
isSelected={false} // History files are not selectable
|
||||||
isSupported={isFileSupported(historyFile.name)}
|
|
||||||
onSelect={() => {}} // No selection for history files
|
onSelect={() => {}} // No selection for history files
|
||||||
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
|
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
|
||||||
onDownload={() => onDownloadSingle(historyFile)}
|
onDownload={() => onDownloadSingle(historyFile)}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
|||||||
onHistoryFileRemove,
|
onHistoryFileRemove,
|
||||||
onFileDoubleClick,
|
onFileDoubleClick,
|
||||||
onDownloadSingle,
|
onDownloadSingle,
|
||||||
isFileSupported,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
activeFileIds,
|
activeFileIds,
|
||||||
} = useFileManagerContext();
|
} = useFileManagerContext();
|
||||||
@@ -65,7 +64,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
|||||||
<FileListItem
|
<FileListItem
|
||||||
file={file}
|
file={file}
|
||||||
isSelected={selectedFilesSet.has(file.id)}
|
isSelected={selectedFilesSet.has(file.id)}
|
||||||
isSupported={isFileSupported(file.name)}
|
|
||||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||||
onRemove={() => onFileRemove(index)}
|
onRemove={() => onFileRemove(index)}
|
||||||
onDownload={() => onDownloadSingle(file)}
|
onDownload={() => onDownloadSingle(file)}
|
||||||
@@ -82,7 +80,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
|||||||
onDownloadSingle={onDownloadSingle}
|
onDownloadSingle={onDownloadSingle}
|
||||||
onFileDoubleClick={onFileDoubleClick}
|
onFileDoubleClick={onFileDoubleClick}
|
||||||
onHistoryFileRemove={onHistoryFileRemove}
|
onHistoryFileRemove={onHistoryFileRemove}
|
||||||
isFileSupported={isFileSupported}
|
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { alert } from "@app/components/toast";
|
|||||||
interface FileListItemProps {
|
interface FileListItemProps {
|
||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isSupported: boolean;
|
|
||||||
onSelect: (shiftKey?: boolean) => void;
|
onSelect: (shiftKey?: boolean) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onDownload?: () => void;
|
onDownload?: () => void;
|
||||||
@@ -53,7 +52,6 @@ interface FileListItemProps {
|
|||||||
const FileListItem: React.FC<FileListItemProps> = ({
|
const FileListItem: React.FC<FileListItemProps> = ({
|
||||||
file,
|
file,
|
||||||
isSelected,
|
isSelected,
|
||||||
isSupported,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onRemove,
|
onRemove,
|
||||||
onDownload,
|
onDownload,
|
||||||
@@ -187,15 +185,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
<Box
|
<Box
|
||||||
p="sm"
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
cursor: isHistoryFile || isActive ? "default" : "pointer",
|
cursor: isHistoryFile ? "default" : "pointer",
|
||||||
backgroundColor: isActive
|
backgroundColor:
|
||||||
? "var(--file-active-bg)"
|
isSelected || shouldShowHovered
|
||||||
: isSelected
|
|
||||||
? "var(--mantine-color-gray-1)"
|
? "var(--mantine-color-gray-1)"
|
||||||
: shouldShowHovered
|
: "var(--bg-file-list)",
|
||||||
? "var(--mantine-color-gray-1)"
|
opacity: 1,
|
||||||
: "var(--bg-file-list)",
|
|
||||||
opacity: isSupported ? 1 : 0.5,
|
|
||||||
transition: "background-color 0.15s ease",
|
transition: "background-color 0.15s ease",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
WebkitUserSelect: "none",
|
WebkitUserSelect: "none",
|
||||||
@@ -206,9 +201,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
? "3px solid var(--mantine-color-blue-4)"
|
? "3px solid var(--mantine-color-blue-4)"
|
||||||
: "none", // Visual indicator for history
|
: "none", // Visual indicator for history
|
||||||
}}
|
}}
|
||||||
onClick={
|
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||||
isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)
|
|
||||||
}
|
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
@@ -218,16 +211,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
<Box>
|
<Box>
|
||||||
{/* Checkbox for regular files only */}
|
{/* Checkbox for regular files only */}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isActive || isSelected}
|
checked={isSelected}
|
||||||
onChange={() => {}} // Handled by parent onClick
|
onChange={() => {}} // Handled by parent onClick
|
||||||
size="sm"
|
size="sm"
|
||||||
pl="sm"
|
pl="sm"
|
||||||
pr="xs"
|
pr="xs"
|
||||||
disabled={isActive}
|
|
||||||
color={isActive ? "green" : undefined}
|
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
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 { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||||
|
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||||
|
|
||||||
interface FileSourceButtonsProps {
|
interface FileSourceButtonsProps {
|
||||||
horizontal?: boolean;
|
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> = ({
|
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||||
horizontal = false,
|
horizontal = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -144,7 +128,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="var(--mantine-color-gray-6)"
|
color="var(--mantine-color-gray-6)"
|
||||||
leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
|
leftSection={<GoogleDriveIcon colored={isGoogleDriveEnabled} />}
|
||||||
justify={horizontal ? "center" : "flex-start"}
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
onClick={handleGoogleDriveClick}
|
onClick={handleGoogleDriveClick}
|
||||||
fullWidth={!horizontal}
|
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 {
|
.workbenchScrollable {
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden !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 { Box, Loader, Center } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
import { useFileState } from "@app/contexts/FileContext";
|
||||||
import {
|
import {
|
||||||
useNavigationState,
|
useNavigationState,
|
||||||
useNavigationActions,
|
useNavigationActions,
|
||||||
useNavigationGuard,
|
|
||||||
} from "@app/contexts/NavigationContext";
|
} from "@app/contexts/NavigationContext";
|
||||||
import { isBaseWorkbench } from "@app/types/workbench";
|
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 { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { FileId } from "@app/types/file";
|
|
||||||
import styles from "@app/components/layout/Workbench.module.css";
|
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 LandingPage from "@app/components/shared/LandingPage";
|
||||||
import Footer from "@app/components/shared/Footer";
|
import Footer from "@app/components/shared/Footer";
|
||||||
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||||
@@ -37,7 +35,6 @@ export default function Workbench() {
|
|||||||
|
|
||||||
// Use context-based hooks to eliminate all prop drilling
|
// Use context-based hooks to eliminate all prop drilling
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
const { actions: fileActions } = useFileActions();
|
|
||||||
const { workbench: currentView } = useNavigationState();
|
const { workbench: currentView } = useNavigationState();
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
const setCurrentView = navActions.setWorkbench;
|
const setCurrentView = navActions.setWorkbench;
|
||||||
@@ -61,34 +58,15 @@ export default function Workbench() {
|
|||||||
const { toolRegistry } = useToolWorkflow();
|
const { toolRegistry } = useToolWorkflow();
|
||||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
|
const hasFiles = activeFiles.length > 0;
|
||||||
|
|
||||||
// Get active file index from ViewerContext
|
// Enable bar transitions after first paint so the initial hidden state shows
|
||||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
// without animating (landing page on load shouldn't animate the bar up).
|
||||||
|
const [barTransitionEnabled, setBarTransitionEnabled] = useState(false);
|
||||||
// Get navigation guard for unsaved changes check when switching files
|
useEffect(() => {
|
||||||
const { requestNavigation } = useNavigationGuard();
|
const raf = requestAnimationFrame(() => setBarTransitionEnabled(true));
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
// 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePreviewClose = () => {
|
const handlePreviewClose = () => {
|
||||||
setPreviewFile(null);
|
setPreviewFile(null);
|
||||||
@@ -130,7 +108,9 @@ export default function Workbench() {
|
|||||||
return (
|
return (
|
||||||
<FileEditor
|
<FileEditor
|
||||||
toolMode={!!selectedToolId}
|
toolMode={!!selectedToolId}
|
||||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
supportedExtensions={
|
||||||
|
selectedTool?.supportedFormats || VIEWER_SUPPORTED_EXTENSIONS
|
||||||
|
}
|
||||||
{...(!selectedToolId && {
|
{...(!selectedToolId && {
|
||||||
onOpenPageEditor: () => {
|
onOpenPageEditor: () => {
|
||||||
setCurrentView("pageEditor");
|
setCurrentView("pageEditor");
|
||||||
@@ -150,8 +130,6 @@ export default function Workbench() {
|
|||||||
setSidebarsVisible={setSidebarsVisible}
|
setSidebarsVisible={setSidebarsVisible}
|
||||||
previewFile={previewFile}
|
previewFile={previewFile}
|
||||||
onClose={handlePreviewClose}
|
onClose={handlePreviewClose}
|
||||||
activeFileIndex={activeFileIndex}
|
|
||||||
setActiveFileIndex={setActiveFileIndex}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -195,7 +173,7 @@ export default function Workbench() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <LandingPage />;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,27 +187,23 @@ export default function Workbench() {
|
|||||||
: { backgroundColor: "var(--bg-background)" }
|
: { backgroundColor: "var(--bg-background)" }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Top Controls */}
|
{/* Workbench Bar - animates in/out based on file presence */}
|
||||||
{activeFiles.length > 0 &&
|
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
?.hideTopControls && (
|
||||||
?.hideTopControls && (
|
<div
|
||||||
<TopControls
|
className={styles.workbenchBarWrapper}
|
||||||
currentView={currentView}
|
data-hidden={String(!hasFiles)}
|
||||||
setCurrentView={setCurrentView}
|
data-no-transition={String(!barTransitionEnabled)}
|
||||||
customViews={customWorkbenchViews}
|
>
|
||||||
activeFiles={activeFiles.map((f) => {
|
<div className={styles.workbenchBarInner}>
|
||||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
<WorkbenchBar
|
||||||
return {
|
currentView={currentView}
|
||||||
fileId: f.fileId,
|
setCurrentView={setCurrentView}
|
||||||
name: f.name,
|
hasFiles={hasFiles}
|
||||||
versionNumber: stub?.versionNumber,
|
/>
|
||||||
};
|
</div>
|
||||||
})}
|
</div>
|
||||||
currentFileIndex={activeFileIndex}
|
)}
|
||||||
onFileSelect={handleFileSelect}
|
|
||||||
onFileRemove={handleFileRemove}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dismiss All Errors Button */}
|
{/* Dismiss All Errors Button */}
|
||||||
<DismissAllErrorsButton />
|
<DismissAllErrorsButton />
|
||||||
|
|||||||
@@ -232,12 +232,15 @@ export default function Onboarding() {
|
|||||||
loadSampleFile: tourOrch.loadSampleFile,
|
loadSampleFile: tourOrch.loadSampleFile,
|
||||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||||
pinFile: tourOrch.pinFile,
|
pinFile: tourOrch.pinFile,
|
||||||
|
revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu,
|
||||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||||
executeTool: tourOrch.executeTool,
|
executeTool: tourOrch.executeTool,
|
||||||
openFilesModal,
|
openFilesModal,
|
||||||
|
openSettingsHelpSection: () =>
|
||||||
|
adminTourOrch.navigateToSection("help"),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[t, tourOrch, closeFilesModal, openFilesModal],
|
[t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const whatsNewStepsConfig = useMemo(
|
const whatsNewStepsConfig = useMemo(
|
||||||
@@ -253,7 +256,6 @@ export default function Onboarding() {
|
|||||||
switchToViewer: tourOrch.switchToViewer,
|
switchToViewer: tourOrch.switchToViewer,
|
||||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||||
selectFirstFile: tourOrch.selectFirstFile,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[t, tourOrch, closeFilesModal, openFilesModal],
|
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
addGlowToElements,
|
addGlowToElements,
|
||||||
removeAllGlows,
|
removeAllGlows,
|
||||||
} from "@app/components/onboarding/tourGlow";
|
} from "@app/components/onboarding/tourGlow";
|
||||||
|
import {
|
||||||
|
waitForElement,
|
||||||
|
waitForHighlightable,
|
||||||
|
} from "@app/components/onboarding/tourUtils";
|
||||||
|
|
||||||
export enum AdminTourStep {
|
export enum AdminTourStep {
|
||||||
WELCOME,
|
WELCOME,
|
||||||
@@ -57,7 +61,7 @@ export function createAdminStepsConfig({
|
|||||||
selector: '[data-tour="config-button"]',
|
selector: '[data-tour="config-button"]',
|
||||||
content: t(
|
content: t(
|
||||||
"adminOnboarding.configButton",
|
"adminOnboarding.configButton",
|
||||||
"Click the <strong>Config</strong> button to access all system settings and administrative controls.",
|
"Open <strong>Settings</strong> to access all system configuration and administrative controls.",
|
||||||
),
|
),
|
||||||
position: "right",
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
@@ -204,15 +208,18 @@ export function createAdminStepsConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AdminTourStep.WRAP_UP]: {
|
[AdminTourStep.WRAP_UP]: {
|
||||||
selector: '[data-tour="help-button"]',
|
selector: '[data-tour="admin-help-nav"]',
|
||||||
content: t(
|
content: t(
|
||||||
"adminOnboarding.wrapUp",
|
"adminOnboarding.wrapUp",
|
||||||
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu.",
|
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
|
||||||
),
|
),
|
||||||
position: "right",
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: async () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
|
navigateToSection("help");
|
||||||
|
await waitForElement('[data-tour="admin-help-nav"]', 5000);
|
||||||
|
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Waits for a CSS selector to appear in the DOM using MutationObserver.
|
||||||
|
* Resolves immediately if already present; resolves after timeoutMs if it
|
||||||
|
* never appears (no throw — tour steps are best-effort).
|
||||||
|
*/
|
||||||
|
export function waitForElement(
|
||||||
|
selector: string,
|
||||||
|
timeoutMs = 7000,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.querySelector(selector)) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (document.querySelector(selector)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
observer.disconnect();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
resolve();
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nudgeReactour = () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a CSS selector to be present AND have a non-zero bounding box,
|
||||||
|
* then nudges Reactour to recalculate its spotlight position.
|
||||||
|
*
|
||||||
|
* Uses MutationObserver to detect element insertion, then ResizeObserver to
|
||||||
|
* detect when the element receives layout dimensions.
|
||||||
|
*/
|
||||||
|
export function waitForHighlightable(
|
||||||
|
selector: string,
|
||||||
|
timeoutMs = 7000,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mutationObserver: MutationObserver | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
mutationObserver?.disconnect();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
nudgeReactour();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(done, timeoutMs);
|
||||||
|
|
||||||
|
const watchLayout = (el: HTMLElement) => {
|
||||||
|
if (el.getClientRects().length > 0) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.contentRect.width > 0 || entry.contentRect.height > 0) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (el) {
|
||||||
|
watchLayout(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutationObserver = new MutationObserver(() => {
|
||||||
|
const found = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (found) {
|
||||||
|
mutationObserver!.disconnect();
|
||||||
|
mutationObserver = null;
|
||||||
|
watchLayout(found);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { StepType } from "@reactour/tour";
|
import type { StepType } from "@reactour/tour";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
import {
|
||||||
|
waitForElement,
|
||||||
|
waitForHighlightable,
|
||||||
|
} from "@app/components/onboarding/tourUtils";
|
||||||
|
|
||||||
export enum TourStep {
|
export enum TourStep {
|
||||||
ALL_TOOLS,
|
ALL_TOOLS,
|
||||||
@@ -26,9 +30,11 @@ interface UserStepActions {
|
|||||||
loadSampleFile: () => void;
|
loadSampleFile: () => void;
|
||||||
switchToActiveFiles: () => void;
|
switchToActiveFiles: () => void;
|
||||||
pinFile: () => void;
|
pinFile: () => void;
|
||||||
|
revealFileCardHoverMenu: () => void;
|
||||||
modifyCropSettings: () => void;
|
modifyCropSettings: () => void;
|
||||||
executeTool: () => void;
|
executeTool: () => void;
|
||||||
openFilesModal: () => void;
|
openFilesModal: () => void;
|
||||||
|
openSettingsHelpSection: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateUserStepsConfigArgs {
|
interface CreateUserStepsConfigArgs {
|
||||||
@@ -48,9 +54,11 @@ export function createUserStepsConfig({
|
|||||||
loadSampleFile,
|
loadSampleFile,
|
||||||
switchToActiveFiles,
|
switchToActiveFiles,
|
||||||
pinFile,
|
pinFile,
|
||||||
|
revealFileCardHoverMenu,
|
||||||
modifyCropSettings,
|
modifyCropSettings,
|
||||||
executeTool,
|
executeTool,
|
||||||
openFilesModal,
|
openFilesModal,
|
||||||
|
openSettingsHelpSection,
|
||||||
} = actions;
|
} = actions;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -95,7 +103,7 @@ export function createUserStepsConfig({
|
|||||||
),
|
),
|
||||||
position: "right",
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => openFilesModal(),
|
actionAfter: () => openFilesModal(),
|
||||||
},
|
},
|
||||||
[TourStep.FILE_SOURCES]: {
|
[TourStep.FILE_SOURCES]: {
|
||||||
selector: '[data-tour="file-sources"]',
|
selector: '[data-tour="file-sources"]',
|
||||||
@@ -105,6 +113,10 @@ export function createUserStepsConfig({
|
|||||||
),
|
),
|
||||||
position: "right",
|
position: "right",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
action: async () => {
|
||||||
|
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||||
|
await waitForHighlightable('[data-tour="file-sources"]', 5000);
|
||||||
|
},
|
||||||
actionAfter: () => {
|
actionAfter: () => {
|
||||||
loadSampleFile();
|
loadSampleFile();
|
||||||
closeFilesModal();
|
closeFilesModal();
|
||||||
@@ -184,16 +196,22 @@ export function createUserStepsConfig({
|
|||||||
),
|
),
|
||||||
position: "left",
|
position: "left",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => pinFile(),
|
action: () => revealFileCardHoverMenu(),
|
||||||
|
actionAfter: () => pinFile(),
|
||||||
},
|
},
|
||||||
[TourStep.WRAP_UP]: {
|
[TourStep.WRAP_UP]: {
|
||||||
selector: '[data-tour="help-button"]',
|
selector: '[data-tour="admin-help-nav"]',
|
||||||
content: t(
|
content: t(
|
||||||
"onboarding.wrapUp",
|
"onboarding.wrapUp",
|
||||||
"You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
|
"You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
|
||||||
),
|
),
|
||||||
position: "right",
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
|
action: async () => {
|
||||||
|
openSettingsHelpSection();
|
||||||
|
await waitForElement('[data-tour="admin-help-nav"]', 5000);
|
||||||
|
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,14 @@
|
|||||||
import type { StepType } from "@reactour/tour";
|
import type { StepType } from "@reactour/tour";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
import {
|
||||||
async function waitForElement(
|
waitForElement,
|
||||||
selector: string,
|
waitForHighlightable,
|
||||||
timeoutMs = 7000,
|
} from "@app/components/onboarding/tourUtils";
|
||||||
intervalMs = 100,
|
|
||||||
): Promise<void> {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
const start = Date.now();
|
|
||||||
// Immediate hit
|
|
||||||
if (document.querySelector(selector)) return;
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const check = () => {
|
|
||||||
if (document.querySelector(selector) || Date.now() - start >= timeoutMs) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(check, intervalMs);
|
|
||||||
};
|
|
||||||
check();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHighlightable(
|
|
||||||
selector: string,
|
|
||||||
timeoutMs = 7000,
|
|
||||||
intervalMs = 500,
|
|
||||||
): Promise<void> {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const check = () => {
|
|
||||||
const el = document.querySelector<HTMLElement>(selector);
|
|
||||||
const isVisible = !!el && el.getClientRects().length > 0;
|
|
||||||
if (isVisible || Date.now() - start >= timeoutMs) {
|
|
||||||
// Nudge Reactour to recalc positions in case layout shifted
|
|
||||||
window.dispatchEvent(new Event("resize"));
|
|
||||||
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(check, intervalMs);
|
|
||||||
};
|
|
||||||
check();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum WhatsNewTourStep {
|
export enum WhatsNewTourStep {
|
||||||
QUICK_ACCESS,
|
QUICK_ACCESS,
|
||||||
LEFT_PANEL,
|
LEFT_PANEL,
|
||||||
FILE_UPLOAD,
|
FILE_UPLOAD,
|
||||||
RIGHT_RAIL,
|
|
||||||
TOP_BAR,
|
TOP_BAR,
|
||||||
PAGE_EDITOR_VIEW,
|
PAGE_EDITOR_VIEW,
|
||||||
ACTIVE_FILES_VIEW,
|
ACTIVE_FILES_VIEW,
|
||||||
@@ -68,7 +24,6 @@ interface WhatsNewStepActions {
|
|||||||
switchToViewer: () => void;
|
switchToViewer: () => void;
|
||||||
switchToPageEditor: () => void;
|
switchToPageEditor: () => void;
|
||||||
switchToActiveFiles: () => void;
|
switchToActiveFiles: () => void;
|
||||||
selectFirstFile: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateWhatsNewStepsConfigArgs {
|
interface CreateWhatsNewStepsConfigArgs {
|
||||||
@@ -89,7 +44,6 @@ export function createWhatsNewStepsConfig({
|
|||||||
switchToViewer,
|
switchToViewer,
|
||||||
switchToPageEditor,
|
switchToPageEditor,
|
||||||
switchToActiveFiles,
|
switchToActiveFiles,
|
||||||
selectFirstFile,
|
|
||||||
} = actions;
|
} = actions;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -124,34 +78,15 @@ export function createWhatsNewStepsConfig({
|
|||||||
),
|
),
|
||||||
position: "right",
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: async () => {
|
|
||||||
openFilesModal();
|
|
||||||
await waitForElement('[data-tour="file-sources"]', 5000, 100);
|
|
||||||
},
|
|
||||||
actionAfter: async () => {
|
actionAfter: async () => {
|
||||||
|
openFilesModal();
|
||||||
|
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||||
await Promise.resolve(loadSampleFile());
|
await Promise.resolve(loadSampleFile());
|
||||||
closeFilesModal();
|
closeFilesModal();
|
||||||
switchToViewer();
|
switchToViewer();
|
||||||
// wait for file render and top controls to mount
|
// wait for file render and top controls to mount
|
||||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||||
},
|
|
||||||
},
|
|
||||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
|
||||||
selector: '[data-tour="right-rail-controls"]',
|
|
||||||
highlightedSelectors: [
|
|
||||||
'[data-tour="right-rail-controls"]',
|
|
||||||
'[data-tour="right-rail-settings"]',
|
|
||||||
],
|
|
||||||
content: t(
|
|
||||||
"onboarding.whatsNew.rightRail",
|
|
||||||
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
|
|
||||||
),
|
|
||||||
position: "left",
|
|
||||||
padding: 10,
|
|
||||||
action: async () => {
|
|
||||||
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
|
|
||||||
selectFirstFile();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[WhatsNewTourStep.TOP_BAR]: {
|
[WhatsNewTourStep.TOP_BAR]: {
|
||||||
@@ -165,8 +100,8 @@ export function createWhatsNewStepsConfig({
|
|||||||
// Ensure the switcher has mounted before this step renders
|
// Ensure the switcher has mounted before this step renders
|
||||||
action: async () => {
|
action: async () => {
|
||||||
switchToViewer();
|
switchToViewer();
|
||||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||||
@@ -179,8 +114,8 @@ export function createWhatsNewStepsConfig({
|
|||||||
padding: 8,
|
padding: 8,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
switchToPageEditor();
|
switchToPageEditor();
|
||||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
||||||
@@ -193,8 +128,8 @@ export function createWhatsNewStepsConfig({
|
|||||||
padding: 8,
|
padding: 8,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
switchToActiveFiles();
|
switchToActiveFiles();
|
||||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[WhatsNewTourStep.WRAP_UP]: {
|
[WhatsNewTourStep.WRAP_UP]: {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
|||||||
import { useInitialPageDocument } from "@app/components/pageEditor/hooks/useInitialPageDocument";
|
import { useInitialPageDocument } from "@app/components/pageEditor/hooks/useInitialPageDocument";
|
||||||
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
|
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
|
||||||
import { usePageEditorState } from "@app/components/pageEditor/hooks/usePageEditorState";
|
import { usePageEditorState } from "@app/components/pageEditor/hooks/usePageEditorState";
|
||||||
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
|
import { usePageEditorWorkbenchBarButtons } from "@app/components/pageEditor/pageEditorWorkbenchBarButtons";
|
||||||
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
|
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
|
||||||
import { useWheelZoom } from "@app/hooks/useWheelZoom";
|
import { useWheelZoom } from "@app/hooks/useWheelZoom";
|
||||||
import { useEditedDocumentState } from "@app/components/pageEditor/hooks/useEditedDocumentState";
|
import { useEditedDocumentState } from "@app/components/pageEditor/hooks/useEditedDocumentState";
|
||||||
@@ -168,6 +168,16 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
}, [pageEditorFiles]);
|
}, [pageEditorFiles]);
|
||||||
const activeFilesSignature = selectors.getFilesSignature();
|
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
|
// UI state
|
||||||
const globalProcessing = state.ui.isProcessing;
|
const globalProcessing = state.ui.isProcessing;
|
||||||
|
|
||||||
@@ -448,11 +458,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
unregisterNavigationWarningHandlers,
|
unregisterNavigationWarningHandlers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
|
// Derived values for usePageEditorWorkbenchBarButtons (must be after displayDocument)
|
||||||
const selectedPageCount = selectedPageIds.length;
|
const selectedPageCount = selectedPageIds.length;
|
||||||
const activeFileIds = selectedFileIds;
|
|
||||||
|
|
||||||
usePageEditorRightRailButtons({
|
usePageEditorWorkbenchBarButtons({
|
||||||
totalPages,
|
totalPages,
|
||||||
selectedPageCount,
|
selectedPageCount,
|
||||||
csvInput,
|
csvInput,
|
||||||
@@ -466,8 +475,6 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
onExportSelected,
|
onExportSelected,
|
||||||
onSaveChanges: applyChanges,
|
onSaveChanges: applyChanges,
|
||||||
exportLoading,
|
exportLoading,
|
||||||
activeFileCount: activeFileIds.length,
|
|
||||||
closePdf,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export preview function - defined after export functions to avoid circular dependency
|
// Export preview function - defined after export functions to avoid circular dependency
|
||||||
@@ -677,23 +684,21 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
>
|
>
|
||||||
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
||||||
|
|
||||||
{!initialDocument &&
|
{!initialDocument && !globalProcessing && !hasPdfFiles && (
|
||||||
!globalProcessing &&
|
<Center h="100%">
|
||||||
selectedFileIds.length === 0 && (
|
<Stack align="center" gap="md">
|
||||||
<Center h="100%">
|
<Text size="lg" c="dimmed">
|
||||||
<Stack align="center" gap="md">
|
📄
|
||||||
<Text size="lg" c="dimmed">
|
</Text>
|
||||||
📄
|
<Text c="dimmed">No PDF files loaded</Text>
|
||||||
</Text>
|
<Text size="sm" c="dimmed">
|
||||||
<Text c="dimmed">No PDF files loaded</Text>
|
Add files to start editing pages
|
||||||
<Text size="sm" c="dimmed">
|
</Text>
|
||||||
Add files to start editing pages
|
</Stack>
|
||||||
</Text>
|
</Center>
|
||||||
</Stack>
|
)}
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!initialDocument && globalProcessing && (
|
{!initialDocument && (globalProcessing || hasPdfFiles) && (
|
||||||
<Box p={0}>
|
<Box p={0}>
|
||||||
<SkeletonLoader type="controls" />
|
<SkeletonLoader type="controls" />
|
||||||
<SkeletonLoader type="pageGrid" count={8} />
|
<SkeletonLoader type="pageGrid" count={8} />
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ interface PageEditorControlsProps {
|
|||||||
onPageBreak: () => void;
|
onPageBreak: () => void;
|
||||||
onPageBreakAll: () => void;
|
onPageBreakAll: () => void;
|
||||||
|
|
||||||
// Export functions (moved to right rail)
|
|
||||||
onExportAll: () => void;
|
onExportAll: () => void;
|
||||||
exportLoading: boolean;
|
exportLoading: boolean;
|
||||||
|
|
||||||
@@ -138,9 +137,7 @@ const PageEditorControls = ({
|
|||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{
|
style={{
|
||||||
color: canUndo
|
color: canUndo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||||
? "var(--right-rail-icon)"
|
|
||||||
: "var(--right-rail-icon-disabled)",
|
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -154,9 +151,7 @@ const PageEditorControls = ({
|
|||||||
disabled={!canRedo}
|
disabled={!canRedo}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{
|
style={{
|
||||||
color: canRedo
|
color: canRedo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||||
? "var(--right-rail-icon)"
|
|
||||||
: "var(--right-rail-icon-disabled)",
|
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -183,8 +178,8 @@ const PageEditorControls = ({
|
|||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
selectedPageIds.length > 0
|
selectedPageIds.length > 0
|
||||||
? "var(--right-rail-icon)"
|
? "var(--text-secondary)"
|
||||||
: "var(--right-rail-icon-disabled)",
|
: "var(--text-muted)",
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -200,8 +195,8 @@ const PageEditorControls = ({
|
|||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
selectedPageIds.length > 0
|
selectedPageIds.length > 0
|
||||||
? "var(--right-rail-icon)"
|
? "var(--text-secondary)"
|
||||||
: "var(--right-rail-icon-disabled)",
|
: "var(--text-muted)",
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -217,8 +212,8 @@ const PageEditorControls = ({
|
|||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
selectedPageIds.length > 0
|
selectedPageIds.length > 0
|
||||||
? "var(--right-rail-icon)"
|
? "var(--text-secondary)"
|
||||||
: "var(--right-rail-icon-disabled)",
|
: "var(--text-muted)",
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -234,8 +229,8 @@ const PageEditorControls = ({
|
|||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
selectedPageIds.length > 0
|
selectedPageIds.length > 0
|
||||||
? "var(--right-rail-icon)"
|
? "var(--text-secondary)"
|
||||||
: "var(--right-rail-icon-disabled)",
|
: "var(--text-muted)",
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -251,8 +246,8 @@ const PageEditorControls = ({
|
|||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
selectedPageIds.length > 0
|
selectedPageIds.length > 0
|
||||||
? "var(--right-rail-icon)"
|
? "var(--text-secondary)"
|
||||||
: "var(--right-rail-icon-disabled)",
|
: "var(--text-muted)",
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -27,19 +27,18 @@ export default function PageSelectByNumberButton({
|
|||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={label}
|
content={label}
|
||||||
position="left"
|
position="bottom"
|
||||||
offset={12}
|
offset={12}
|
||||||
arrow
|
arrow
|
||||||
portalTarget={document.body}
|
portalTarget={document.body}
|
||||||
>
|
>
|
||||||
<div className={`right-rail-fade enter`}>
|
<div>
|
||||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<div style={{ display: "inline-flex" }}>
|
<div style={{ display: "inline-flex" }}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
|
||||||
disabled={disabled || totalPages === 0}
|
disabled={disabled || totalPages === 0}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
|
|||||||
+14
-34
@@ -1,13 +1,13 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
useRightRailButtons,
|
useWorkbenchBarButtons,
|
||||||
RightRailButtonWithAction,
|
WorkbenchBarButtonWithAction,
|
||||||
} from "@app/hooks/useRightRailButtons";
|
} from "@app/hooks/useWorkbenchBarButtons";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||||
|
|
||||||
interface PageEditorRightRailButtonsParams {
|
interface PageEditorWorkbenchBarButtonsParams {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
selectedPageCount: number;
|
selectedPageCount: number;
|
||||||
csvInput: string;
|
csvInput: string;
|
||||||
@@ -21,12 +21,10 @@ interface PageEditorRightRailButtonsParams {
|
|||||||
onExportSelected: () => void;
|
onExportSelected: () => void;
|
||||||
onSaveChanges: () => void;
|
onSaveChanges: () => void;
|
||||||
exportLoading: boolean;
|
exportLoading: boolean;
|
||||||
activeFileCount: number;
|
|
||||||
closePdf: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePageEditorRightRailButtons(
|
export function usePageEditorWorkbenchBarButtons(
|
||||||
params: PageEditorRightRailButtonsParams,
|
params: PageEditorWorkbenchBarButtonsParams,
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
totalPages,
|
totalPages,
|
||||||
@@ -42,31 +40,27 @@ export function usePageEditorRightRailButtons(
|
|||||||
onExportSelected,
|
onExportSelected,
|
||||||
onSaveChanges,
|
onSaveChanges,
|
||||||
exportLoading,
|
exportLoading,
|
||||||
activeFileCount,
|
|
||||||
closePdf,
|
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
// Lift i18n labels out of memo for clarity
|
// Lift i18n labels out of memo for clarity
|
||||||
const selectAllLabel = t("rightRail.selectAll", "Select All");
|
const selectAllLabel = t("workbenchBar.selectAll", "Select All");
|
||||||
const deselectAllLabel = t("rightRail.deselectAll", "Deselect All");
|
const deselectAllLabel = t("workbenchBar.deselectAll", "Deselect All");
|
||||||
const selectByNumberLabel = t(
|
const selectByNumberLabel = t(
|
||||||
"rightRail.selectByNumber",
|
"workbenchBar.selectByNumber",
|
||||||
"Select by Page Numbers",
|
"Select by Page Numbers",
|
||||||
);
|
);
|
||||||
const deleteSelectedLabel = t(
|
const deleteSelectedLabel = t(
|
||||||
"rightRail.deleteSelected",
|
"workbenchBar.deleteSelected",
|
||||||
"Delete Selected Pages",
|
"Delete Selected Pages",
|
||||||
);
|
);
|
||||||
const exportSelectedLabel = t(
|
const exportSelectedLabel = t(
|
||||||
"rightRail.exportSelected",
|
"workbenchBar.exportSelected",
|
||||||
"Export Selected Pages",
|
"Export Selected Pages",
|
||||||
);
|
);
|
||||||
const saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
|
const saveChangesLabel = t("workbenchBar.saveChanges", "Save Changes");
|
||||||
const closePdfLabel = t("rightRail.closePdf", "Close PDF");
|
const buttons = useMemo<WorkbenchBarButtonWithAction[]>(() => {
|
||||||
|
|
||||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "page-select-all",
|
id: "page-select-all",
|
||||||
@@ -156,17 +150,6 @@ export function usePageEditorRightRailButtons(
|
|||||||
visible: totalPages > 0,
|
visible: totalPages > 0,
|
||||||
onClick: onSaveChanges,
|
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,
|
t,
|
||||||
@@ -177,7 +160,6 @@ export function usePageEditorRightRailButtons(
|
|||||||
deleteSelectedLabel,
|
deleteSelectedLabel,
|
||||||
exportSelectedLabel,
|
exportSelectedLabel,
|
||||||
saveChangesLabel,
|
saveChangesLabel,
|
||||||
closePdfLabel,
|
|
||||||
totalPages,
|
totalPages,
|
||||||
selectedPageCount,
|
selectedPageCount,
|
||||||
csvInput,
|
csvInput,
|
||||||
@@ -191,9 +173,7 @@ export function usePageEditorRightRailButtons(
|
|||||||
onExportSelected,
|
onExportSelected,
|
||||||
onSaveChanges,
|
onSaveChanges,
|
||||||
exportLoading,
|
exportLoading,
|
||||||
activeFileCount,
|
|
||||||
closePdf,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useRightRailButtons(buttons);
|
useWorkbenchBarButtons(buttons);
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
interface RightRailFooterExtensionsProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RightRailFooterExtensions(
|
|
||||||
_props: RightRailFooterExtensionsProps,
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -108,11 +108,26 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
|||||||
const runningEE = config?.runningEE ?? false;
|
const runningEE = config?.runningEE ?? false;
|
||||||
const loginEnabled = config?.enableLogin ?? false;
|
const loginEnabled = config?.enableLogin ?? false;
|
||||||
|
|
||||||
|
const handleClose = useCallback(async () => {
|
||||||
|
const canProceed = await confirmIfDirty();
|
||||||
|
if (!canProceed) return;
|
||||||
|
|
||||||
|
// Navigate back to home when closing modal
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
onClose();
|
||||||
|
}, [confirmIfDirty, navigate, onClose]);
|
||||||
|
|
||||||
|
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
|
||||||
|
const handleCloseSync = useCallback(() => {
|
||||||
|
void handleClose();
|
||||||
|
}, [handleClose]);
|
||||||
|
|
||||||
// Left navigation structure and icons
|
// Left navigation structure and icons
|
||||||
const configNavSections = useConfigNavSections(
|
const configNavSections = useConfigNavSections(
|
||||||
isAdmin,
|
isAdmin,
|
||||||
runningEE,
|
runningEE,
|
||||||
loginEnabled,
|
loginEnabled,
|
||||||
|
handleCloseSync,
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeLabel = useMemo(() => {
|
const activeLabel = useMemo(() => {
|
||||||
@@ -131,15 +146,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}, [configNavSections, active]);
|
}, [configNavSections, active]);
|
||||||
|
|
||||||
const handleClose = useCallback(async () => {
|
|
||||||
const canProceed = await confirmIfDirty();
|
|
||||||
if (!canProceed) return;
|
|
||||||
|
|
||||||
// Navigate back to home when closing modal
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
onClose();
|
|
||||||
}, [confirmIfDirty, navigate, onClose]);
|
|
||||||
|
|
||||||
const handleNavigation = useCallback(
|
const handleNavigation = useCallback(
|
||||||
async (key: NavKey) => {
|
async (key: NavKey) => {
|
||||||
const canProceed = await confirmIfDirty();
|
const canProceed = await confirmIfDirty();
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("storageShare.fileCount", "{{count}} files selected", {
|
{t("storageShare.fileCount", "{{count}} files", {
|
||||||
count: files.length,
|
count: files.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("storageUpload.fileCount", "{{count}} files selected", {
|
{t("storageUpload.fileCount", "{{count}} files", {
|
||||||
count: files.length,
|
count: files.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</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,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Box,
|
Box,
|
||||||
Image,
|
|
||||||
ThemeIcon,
|
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Loader,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
import StorageIcon from "@mui/icons-material/Storage";
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
|
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 {
|
interface FileCardProps {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -47,15 +44,15 @@ const FileCard = ({
|
|||||||
isSupported = true,
|
isSupported = true,
|
||||||
}: FileCardProps) => {
|
}: FileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
const {
|
||||||
const { thumbnail: indexedDBThumb, isGenerating } =
|
isEncrypted,
|
||||||
useIndexedDBThumbnail(fileStub);
|
thumbnail: thumb,
|
||||||
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
|
isGenerating,
|
||||||
|
} = useFileThumbnail(fileStub);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
// Show loading state during hydration: PDF file without thumbnail yet
|
|
||||||
const isPdf = file.type === "application/pdf";
|
const isPdf = file.type === "application/pdf";
|
||||||
const isHydrating = isPdf && !thumb && !isGenerating;
|
const isHydrating = isPdf && !isEncrypted && !thumb && !isGenerating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -73,8 +70,7 @@ const FileCard = ({
|
|||||||
? "2px solid var(--mantine-color-blue-6)"
|
? "2px solid var(--mantine-color-blue-6)"
|
||||||
: undefined,
|
: undefined,
|
||||||
backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined,
|
backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined,
|
||||||
opacity: isSupported ? 1 : 0.5,
|
opacity: 1,
|
||||||
filter: isSupported ? "none" : "grayscale(50%)",
|
|
||||||
}}
|
}}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
@@ -146,51 +142,13 @@ const FileCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{thumb ? (
|
<DocumentThumbnail
|
||||||
<Image
|
file={file}
|
||||||
src={thumb}
|
thumbnail={thumb || undefined}
|
||||||
alt="PDF thumbnail"
|
isEncrypted={isEncrypted}
|
||||||
height={110}
|
isLoading={isGenerating || isHydrating}
|
||||||
width={80}
|
iconSize="3rem"
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown
|
<Menu.Dropdown
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--right-rail-bg)",
|
backgroundColor: "var(--bg-file-manager)",
|
||||||
border: "1px solid var(--border-subtle)",
|
border: "1px solid var(--border-subtle)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Box,
|
Box,
|
||||||
Image,
|
|
||||||
Badge,
|
Badge,
|
||||||
ThemeIcon,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||||
import { FileId } from "@app/types/file";
|
import { FileId } from "@app/types/file";
|
||||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
|
|
||||||
@@ -200,26 +198,23 @@ const FilePickerModal = ({
|
|||||||
height: 80,
|
height: 80,
|
||||||
border: "1px solid var(--mantine-color-gray-3)",
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
display: "flex",
|
backgroundColor: "white",
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "var(--mantine-color-gray-0)",
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{file.thumbnail ? (
|
<DocumentThumbnail
|
||||||
<Image
|
file={file}
|
||||||
src={file.thumbnail}
|
thumbnail={
|
||||||
alt="PDF thumbnail"
|
file.processedFile?.isEncrypted
|
||||||
height={70}
|
? undefined
|
||||||
width={50}
|
: file.thumbnail
|
||||||
fit="contain"
|
}
|
||||||
/>
|
isEncrypted={Boolean(
|
||||||
) : (
|
file.processedFile?.isEncrypted,
|
||||||
<ThemeIcon variant="light" color="red" size={40}>
|
)}
|
||||||
<PictureAsPdfIcon style={{ fontSize: 24 }} />
|
iconSize="2rem"
|
||||||
</ThemeIcon>
|
/>
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* File info */}
|
{/* 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", "A–Z")}
|
||||||
|
{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,408 @@
|
|||||||
|
/* ========== FILE SIDEBAR ========== */
|
||||||
|
|
||||||
|
.file-sidebar {
|
||||||
|
background-color: var(--bg-toolbar);
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
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 {
|
||||||
|
height: 22px;
|
||||||
|
width: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 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,594 @@
|
|||||||
|
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 { Wordmark } from "@app/components/shared/Wordmark";
|
||||||
|
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"
|
||||||
|
data-tour="quick-access-bar"
|
||||||
|
>
|
||||||
|
<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 && (
|
||||||
|
<Wordmark
|
||||||
|
alt="Stirling PDF"
|
||||||
|
className="file-sidebar-brand-text sidebar-content-fade"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
|
data-tour="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}
|
||||||
|
data-tour={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,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export interface HoverAction {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
dataTour?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HoverActionMenuProps {
|
interface HoverActionMenuProps {
|
||||||
@@ -18,6 +19,7 @@ interface HoverActionMenuProps {
|
|||||||
actions: HoverAction[];
|
actions: HoverAction[];
|
||||||
position?: "inside" | "outside";
|
position?: "inside" | "outside";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
visibility?: "state" | "cssHover";
|
||||||
}
|
}
|
||||||
|
|
||||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||||
@@ -25,6 +27,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
|||||||
actions,
|
actions,
|
||||||
position = "inside",
|
position = "inside",
|
||||||
className = "",
|
className = "",
|
||||||
|
visibility = "state",
|
||||||
}) => {
|
}) => {
|
||||||
const visibleActions = actions.filter((action) => !action.hidden);
|
const visibleActions = actions.filter((action) => !action.hidden);
|
||||||
|
|
||||||
@@ -32,10 +35,23 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.hoverMenu} ${position === "outside" ? styles.outside : styles.inside} ${className}`}
|
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()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onMouseUp={(e) => e.stopPropagation()}
|
onMouseUp={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -48,7 +64,8 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
|||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
c={action.color}
|
c={action.color}
|
||||||
style={{ color: action.color || "var(--right-rail-icon)" }}
|
style={{ color: action.color || "var(--text-secondary)" }}
|
||||||
|
data-tour={action.dataTour}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface InfoBannerProps {
|
|||||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||||
minHeight?: number | string;
|
minHeight?: number | string;
|
||||||
closeIconColor?: string;
|
closeIconColor?: string;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +76,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
buttonVariant = "light",
|
buttonVariant = "light",
|
||||||
minHeight = 56,
|
minHeight = 56,
|
||||||
closeIconColor,
|
closeIconColor,
|
||||||
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return null;
|
return null;
|
||||||
@@ -85,13 +87,21 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
onDismiss?.();
|
onDismiss?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iconSize = compact ? "1rem" : "1.2rem";
|
||||||
|
const textSize = compact ? "xs" : "sm";
|
||||||
|
const buttonSize = compact ? "xs" : "xs";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
p="sm"
|
p={compact ? "xs" : "sm"}
|
||||||
radius={0}
|
radius={0}
|
||||||
style={{
|
style={{
|
||||||
background: background ?? toneStyle.background,
|
background: background ?? toneStyle.background,
|
||||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
border: "none",
|
||||||
|
borderBottom:
|
||||||
|
borderColor === "transparent"
|
||||||
|
? "none"
|
||||||
|
: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||||
minHeight,
|
minHeight,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -105,22 +115,22 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
>
|
>
|
||||||
<Group
|
<Group
|
||||||
gap="sm"
|
gap={compact ? "xs" : "sm"}
|
||||||
align="center"
|
align="center"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
>
|
>
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
icon={icon}
|
icon={icon}
|
||||||
width="1.2rem"
|
width={iconSize}
|
||||||
height="1.2rem"
|
height={iconSize}
|
||||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
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 && (
|
{title && (
|
||||||
<Text
|
<Text
|
||||||
fw={600}
|
fw={600}
|
||||||
size="sm"
|
size={textSize}
|
||||||
style={{ color: textColor ?? toneStyle.text }}
|
style={{ color: textColor ?? toneStyle.text }}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -128,9 +138,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
)}
|
)}
|
||||||
<Text
|
<Text
|
||||||
fw={title ? 400 : 500}
|
fw={title ? 400 : 500}
|
||||||
size="sm"
|
size={textSize}
|
||||||
style={{ color: textColor ?? toneStyle.text }}
|
style={{ color: textColor ?? toneStyle.text }}
|
||||||
lineClamp={2}
|
lineClamp={compact ? 1 : 2}
|
||||||
>
|
>
|
||||||
{message}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -141,11 +151,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant={buttonVariant}
|
variant={buttonVariant}
|
||||||
color={buttonColor ?? toneStyle.buttonColor}
|
color={buttonColor ?? toneStyle.buttonColor}
|
||||||
size="xs"
|
size={buttonSize}
|
||||||
onClick={onButtonClick}
|
onClick={onButtonClick}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
leftSection={
|
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}
|
{buttonText}
|
||||||
@@ -160,7 +174,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
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>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -5,14 +5,11 @@
|
|||||||
|
|
||||||
/* ── Hero text ───────────────────────────────────────────── */
|
/* ── Hero text ───────────────────────────────────────────── */
|
||||||
.landing-title {
|
.landing-title {
|
||||||
margin: 0;
|
display: block;
|
||||||
margin-top: 1.75rem;
|
margin: 1.75rem auto 0.5rem;
|
||||||
margin-bottom: 0.5rem;
|
height: 3rem;
|
||||||
text-align: center;
|
width: auto;
|
||||||
font-size: 2.125rem;
|
max-width: 100%;
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-subtitle {
|
.landing-subtitle {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
|||||||
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
||||||
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
|
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
|
||||||
import { LandingActions } from "@app/components/shared/LandingActions";
|
import { LandingActions } from "@app/components/shared/LandingActions";
|
||||||
|
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||||
import "@app/components/shared/LandingPage.css";
|
import "@app/components/shared/LandingPage.css";
|
||||||
|
|
||||||
const LandingPage = () => {
|
const LandingPage = () => {
|
||||||
@@ -79,9 +80,10 @@ const LandingPage = () => {
|
|||||||
>
|
>
|
||||||
<LandingDocumentStack />
|
<LandingDocumentStack />
|
||||||
|
|
||||||
<h1 className="landing-title">
|
<Wordmark
|
||||||
{t("landing.heroTitle", "Stirling PDF")}
|
alt={t("landing.heroTitle", "Stirling PDF")}
|
||||||
</h1>
|
className="landing-title"
|
||||||
|
/>
|
||||||
<p className="landing-subtitle">
|
<p className="landing-subtitle">
|
||||||
{t(
|
{t(
|
||||||
"landing.heroSubtitle",
|
"landing.heroSubtitle",
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
|||||||
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
color: disabled
|
color: disabled
|
||||||
? "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))"
|
? "var(--text-muted)"
|
||||||
: isSelected
|
: isSelected
|
||||||
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
|
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-3))"
|
||||||
: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))",
|
: "var(--text-primary)",
|
||||||
transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
"&:hover": !disabled
|
"&:hover": !disabled
|
||||||
@@ -274,11 +274,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
data-testid="language-selector-button"
|
||||||
title={!opened && tooltip ? tooltip : undefined}
|
title={!opened && tooltip ? tooltip : undefined}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
color: "var(--right-rail-icon)",
|
color: "var(--text-secondary)",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
"light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
|
"light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
|
||||||
@@ -292,14 +292,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="language-selector-button"
|
||||||
leftSection={
|
leftSection={
|
||||||
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
border: "none",
|
border: "none",
|
||||||
color:
|
color: "var(--text-primary)",
|
||||||
"light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))",
|
|
||||||
transition:
|
transition:
|
||||||
"background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
"background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ const MultiSelectControls = ({
|
|||||||
>
|
>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{selectedCount} {t("fileManager.filesSelected", "files selected")}
|
{selectedCount} {t("fileManager.filesSelected", "files")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group>
|
<Group>
|
||||||
<Button size="xs" variant="light" onClick={onClearSelection}>
|
<Button size="xs" variant="light" onClick={onClearSelection}>
|
||||||
{t("fileManager.clearSelection", "Clear Selection")}
|
{t("fileManager.clearSelection", "Clear files")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{onAddToUpload && (
|
{onAddToUpload && (
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
|||||||
<Menu.Dropdown
|
<Menu.Dropdown
|
||||||
className="ph-no-capture"
|
className="ph-no-capture"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--right-rail-bg)",
|
backgroundColor: "var(--bg-file-manager)",
|
||||||
border: "1px solid var(--border-subtle)",
|
border: "1px solid var(--border-subtle)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { ActionIcon, Divider } from "@mantine/core";
|
|
||||||
import "@app/components/shared/rightRail/RightRail.css";
|
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
|
||||||
import { useRightRail } from "@app/contexts/RightRailContext";
|
|
||||||
import {
|
|
||||||
useFileState,
|
|
||||||
useFileSelection,
|
|
||||||
useFileActions,
|
|
||||||
} from "@app/contexts/FileContext";
|
|
||||||
import { isStirlingFile } from "@app/types/fileContext";
|
|
||||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
|
||||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
|
||||||
|
|
||||||
import LanguageSelector from "@app/components/shared/LanguageSelector";
|
|
||||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
|
||||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
|
||||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
|
||||||
import { RightRailFooterExtensions } from "@app/components/rightRail/RightRailFooterExtensions";
|
|
||||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
|
||||||
import LightModeIcon from "@mui/icons-material/LightMode";
|
|
||||||
|
|
||||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
|
||||||
import {
|
|
||||||
RightRailButtonConfig,
|
|
||||||
RightRailRenderContext,
|
|
||||||
RightRailSection,
|
|
||||||
} from "@app/types/rightRail";
|
|
||||||
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
|
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
|
||||||
|
|
||||||
const SECTION_ORDER: RightRailSection[] = ["top", "middle", "bottom"];
|
|
||||||
|
|
||||||
function renderWithTooltip(
|
|
||||||
node: React.ReactNode,
|
|
||||||
tooltip: React.ReactNode | undefined,
|
|
||||||
position: "left" | "right",
|
|
||||||
offset: number,
|
|
||||||
) {
|
|
||||||
if (!tooltip) return node;
|
|
||||||
|
|
||||||
const portalTarget =
|
|
||||||
typeof document !== "undefined" ? document.body : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
content={tooltip}
|
|
||||||
position={position}
|
|
||||||
offset={offset}
|
|
||||||
arrow
|
|
||||||
portalTarget={portalTarget}
|
|
||||||
>
|
|
||||||
<div className="right-rail-tooltip-wrapper">{node}</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RightRail() {
|
|
||||||
const { sidebarRefs } = useSidebarContext();
|
|
||||||
const { position: tooltipPosition, offset: tooltipOffset } =
|
|
||||||
useRightRailTooltipSide(sidebarRefs);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const terminology = useFileActionTerminology();
|
|
||||||
const icons = useFileActionIcons();
|
|
||||||
const viewerContext = React.useContext(ViewerContext);
|
|
||||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
|
||||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
|
||||||
|
|
||||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } =
|
|
||||||
useToolWorkflow();
|
|
||||||
const disableForFullscreen =
|
|
||||||
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
|
||||||
|
|
||||||
const { workbench: currentView } = useNavigationState();
|
|
||||||
|
|
||||||
const { selectors } = useFileState();
|
|
||||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
|
||||||
const { actions: fileActions } = useFileActions();
|
|
||||||
const activeFiles = selectors.getFiles();
|
|
||||||
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 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 className = ["right-rail-icon", btn.className]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
const buttonNode = (
|
|
||||||
<ActionIcon
|
|
||||||
variant={isActive ? "filled" : "subtle"}
|
|
||||||
color={isActive ? "blue" : undefined}
|
|
||||||
radius="md"
|
|
||||||
className={className}
|
|
||||||
onClick={triggerAction}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-pressed={isActive ? true : undefined}
|
|
||||||
data-active={isActive ? "true" : "false"}
|
|
||||||
>
|
|
||||||
{btn.icon}
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
|
|
||||||
return renderWithTooltip(
|
|
||||||
buttonNode,
|
|
||||||
btn.tooltip,
|
|
||||||
tooltipPosition,
|
|
||||||
tooltipOffset,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
actions,
|
|
||||||
allButtonsDisabled,
|
|
||||||
disableForFullscreen,
|
|
||||||
tooltipPosition,
|
|
||||||
tooltipOffset,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
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("[RightRail] Failed to export viewer file:", error);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentView === "pageEditor") {
|
|
||||||
pageEditorFunctions?.onExportAll?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filesToExport =
|
|
||||||
selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
|
||||||
|
|
||||||
if (filesToExport.length > 0) {
|
|
||||||
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(
|
|
||||||
"[RightRail] Failed to export file:",
|
|
||||||
file.name,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
currentView,
|
|
||||||
selectedFiles,
|
|
||||||
activeFiles,
|
|
||||||
pageEditorFunctions,
|
|
||||||
viewerContext,
|
|
||||||
selectors,
|
|
||||||
fileActions,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={sidebarRefs.rightRailRef}
|
|
||||||
className="right-rail"
|
|
||||||
data-sidebar="right-rail"
|
|
||||||
>
|
|
||||||
<div className="right-rail-inner">
|
|
||||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
|
|
||||||
<React.Fragment key={section}>
|
|
||||||
<div className="right-rail-section" data-tour="right-rail-controls">
|
|
||||||
{sectionButtons.map((btn, index) => {
|
|
||||||
const content = renderButton(btn);
|
|
||||||
if (!content) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={btn.id}
|
|
||||||
className="right-rail-button-wrapper"
|
|
||||||
style={{ animationDelay: `${index * 50}ms` }}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Divider className="right-rail-divider" />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
data-tour="right-rail-settings"
|
|
||||||
>
|
|
||||||
{renderWithTooltip(
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
radius="md"
|
|
||||||
className="right-rail-icon"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
>
|
|
||||||
{themeMode === "dark" ? (
|
|
||||||
<LightModeIcon sx={{ fontSize: "1.5rem" }} />
|
|
||||||
) : (
|
|
||||||
<DarkModeIcon sx={{ fontSize: "1.5rem" }} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>,
|
|
||||||
t("rightRail.toggleTheme", "Toggle Theme"),
|
|
||||||
tooltipPosition,
|
|
||||||
tooltipOffset,
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LanguageSelector
|
|
||||||
position="left-start"
|
|
||||||
offset={6}
|
|
||||||
compact
|
|
||||||
tooltip={t("rightRail.language", "Language")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{renderWithTooltip(
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
radius="md"
|
|
||||||
className="right-rail-icon"
|
|
||||||
onClick={() => handleExportAll()}
|
|
||||||
disabled={
|
|
||||||
disableForFullscreen ||
|
|
||||||
(currentView !== "viewer" &&
|
|
||||||
(totalItems === 0 || allButtonsDisabled))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LocalIcon
|
|
||||||
icon={icons.downloadIconName}
|
|
||||||
width="1.5rem"
|
|
||||||
height="1.5rem"
|
|
||||||
/>
|
|
||||||
</ActionIcon>,
|
|
||||||
downloadTooltip,
|
|
||||||
tooltipPosition,
|
|
||||||
tooltipOffset,
|
|
||||||
)}
|
|
||||||
{icons.saveAsIconName &&
|
|
||||||
renderWithTooltip(
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
radius="md"
|
|
||||||
className="right-rail-icon"
|
|
||||||
onClick={() => handleExportAll(true)}
|
|
||||||
disabled={
|
|
||||||
disableForFullscreen ||
|
|
||||||
(currentView !== "viewer" &&
|
|
||||||
(totalItems === 0 || allButtonsDisabled))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LocalIcon
|
|
||||||
icon={icons.saveAsIconName}
|
|
||||||
width="1.5rem"
|
|
||||||
height="1.5rem"
|
|
||||||
/>
|
|
||||||
</ActionIcon>,
|
|
||||||
t("rightRail.saveAs", "Save As"),
|
|
||||||
tooltipPosition,
|
|
||||||
tooltipOffset,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="right-rail-spacer" />
|
|
||||||
|
|
||||||
<RightRailFooterExtensions className="right-rail-footer" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,8 @@ export interface TextInputProps {
|
|||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
/** Focus event handler */
|
/** Focus event handler */
|
||||||
onFocus?: () => void;
|
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>(
|
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||||
@@ -56,6 +58,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
"aria-label": ariaLabel,
|
"aria-label": ariaLabel,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
iconClickable = false,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -76,7 +79,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
{icon && (
|
{icon && (
|
||||||
<span
|
<span
|
||||||
className={styles.icon}
|
className={styles.icon}
|
||||||
style={{ color: "var(--search-text-and-icon-color)" }}
|
style={{
|
||||||
|
pointerEvents: iconClickable ? "auto" : "none",
|
||||||
|
left: "12px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
@@ -96,8 +102,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--search-text-and-icon-color)",
|
|
||||||
paddingRight: shouldShowClearButton ? "40px" : "12px",
|
paddingRight: shouldShowClearButton ? "40px" : "12px",
|
||||||
paddingLeft: icon ? "40px" : "12px",
|
paddingLeft: icon ? "40px" : "12px",
|
||||||
}}
|
}}
|
||||||
@@ -108,7 +112,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.clearButton}
|
className={styles.clearButton}
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
style={{ color: "var(--search-text-and-icon-color)" }}
|
|
||||||
aria-label="Clear input"
|
aria-label="Clear input"
|
||||||
>
|
>
|
||||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
<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,187 @@
|
|||||||
|
/* ========== WORKBENCH BAR ========== */
|
||||||
|
/* Horizontal toolbar at the top of the workbench area. */
|
||||||
|
|
||||||
|
.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 { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||||
|
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 {
|
||||||
|
WorkbenchBarButtonConfig,
|
||||||
|
WorkbenchBarRenderContext,
|
||||||
|
WorkbenchBarSection,
|
||||||
|
} from "@app/types/workbenchBar";
|
||||||
|
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: WorkbenchBarSection[] = ["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 } = useWorkbenchBar();
|
||||||
|
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("workbenchBar.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: WorkbenchBarButtonConfig) => {
|
||||||
|
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: WorkbenchBarRenderContext = {
|
||||||
|
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" data-tour="view-switcher">
|
||||||
|
{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("workbenchBar.print", "Print PDF")}
|
||||||
|
>
|
||||||
|
<PrintIcon sx={{ fontSize: "1rem" }} />
|
||||||
|
</ActionIcon>,
|
||||||
|
t("workbenchBar.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("workbenchBar.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("workbenchBar.closeAll", "Close All")
|
||||||
|
: t("workbenchBar.closePdf", "Close PDF")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CloseIcon sx={{ fontSize: "1rem" }} />
|
||||||
|
</ActionIcon>,
|
||||||
|
currentView === "fileEditor"
|
||||||
|
? t("workbenchBar.closeAll", "Close All")
|
||||||
|
: t("workbenchBar.closePdf", "Close PDF"),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { NavKey } from "@app/components/shared/config/types";
|
import { NavKey } from "@app/components/shared/config/types";
|
||||||
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||||
|
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
|
||||||
|
|
||||||
export interface ConfigNavItem {
|
export interface ConfigNavItem {
|
||||||
key: NavKey;
|
key: NavKey;
|
||||||
@@ -34,6 +35,7 @@ export const useConfigNavSections = (
|
|||||||
_isAdmin: boolean = false,
|
_isAdmin: boolean = false,
|
||||||
_runningEE: boolean = false,
|
_runningEE: boolean = false,
|
||||||
_loginEnabled: boolean = false,
|
_loginEnabled: boolean = false,
|
||||||
|
onRequestClose: () => void = () => {},
|
||||||
): ConfigNavSection[] => {
|
): ConfigNavSection[] => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -55,6 +57,19 @@ export const useConfigNavSections = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("settings.help.title", "Help"),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "help",
|
||||||
|
label: t("settings.help.label", "Tours"),
|
||||||
|
icon: "help-rounded",
|
||||||
|
component: (
|
||||||
|
<HelpSection isAdmin={_isAdmin} onRequestClose={onRequestClose} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
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 { ToolPanelMode } from "@app/constants/toolPanel";
|
||||||
import type {
|
import type {
|
||||||
StartupView,
|
StartupView,
|
||||||
@@ -47,6 +49,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { preferences, updatePreference } = usePreferences();
|
const { preferences, updatePreference } = usePreferences();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
|
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||||
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
|
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
|
||||||
preferences.autoUnzipFileLimit,
|
preferences.autoUnzipFileLimit,
|
||||||
);
|
);
|
||||||
@@ -355,6 +358,67 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
</Paper>
|
</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">
|
<Paper withBorder p="md" radius="md">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div
|
<div
|
||||||
@@ -412,7 +476,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
<Text size="xs" c="dimmed" mt={4}>
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
{t(
|
{t(
|
||||||
"settings.general.defaultStartupViewDescription",
|
"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>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
import { requestStartTour } from "@app/constants/events";
|
||||||
|
|
||||||
|
interface HelpSectionProps {
|
||||||
|
isAdmin: boolean;
|
||||||
|
onRequestClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HelpSection: React.FC<HelpSectionProps> = ({
|
||||||
|
isAdmin,
|
||||||
|
onRequestClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const startTour = (tourType: "tools" | "admin") => {
|
||||||
|
onRequestClose();
|
||||||
|
setTimeout(() => requestStartTour(tourType), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t("settings.help.toolsTour.title", "Tools Tour")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t(
|
||||||
|
"settings.help.toolsTour.description",
|
||||||
|
"Walk through uploading files, picking a tool, and reviewing results.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
leftSection={
|
||||||
|
<LocalIcon
|
||||||
|
icon="build-outline-rounded"
|
||||||
|
width="1rem"
|
||||||
|
height="1rem"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={() => startTour("tools")}
|
||||||
|
>
|
||||||
|
{t("settings.help.toolsTour.start", "Start")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t("settings.help.adminTour.title", "Admin Tour")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t(
|
||||||
|
"settings.help.adminTour.description",
|
||||||
|
"Explore team management, system settings, and enterprise features.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
leftSection={
|
||||||
|
<LocalIcon icon="person-rounded" width="1rem" height="1rem" />
|
||||||
|
}
|
||||||
|
onClick={() => startTour("admin")}
|
||||||
|
>
|
||||||
|
{t("settings.help.adminTour.start", "Start")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HelpSection;
|
||||||
@@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [
|
|||||||
"adminUsage",
|
"adminUsage",
|
||||||
"adminEndpoints",
|
"adminEndpoints",
|
||||||
"adminStorageSharing",
|
"adminStorageSharing",
|
||||||
|
"help",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Derive the type from the array
|
// Derive the type from the array
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import React from "react";
|
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 { getFileTypeIcon } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
|
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||||
|
|
||||||
export interface DocumentThumbnailProps {
|
export interface DocumentThumbnailProps {
|
||||||
file: File | StirlingFileStub | null;
|
file: File | StirlingFileStub | null;
|
||||||
thumbnail?: string | null;
|
thumbnail?: string | null;
|
||||||
|
isEncrypted?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
iconSize?: string | number;
|
||||||
|
imgClassName?: string;
|
||||||
|
onImageError?: React.ReactEventHandler<HTMLImageElement>;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -15,14 +22,19 @@ export interface DocumentThumbnailProps {
|
|||||||
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||||
file,
|
file,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
isEncrypted = false,
|
||||||
|
isLoading = false,
|
||||||
|
iconSize = "4rem",
|
||||||
|
imgClassName,
|
||||||
|
onImageError,
|
||||||
style = {},
|
style = {},
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
const containerStyle = {
|
const containerStyle: React.CSSProperties = {
|
||||||
position: "relative" as const,
|
position: "relative",
|
||||||
cursor: onClick ? "pointer" : "default",
|
cursor: onClick ? "pointer" : "default",
|
||||||
transition: "opacity 0.2s ease",
|
transition: "opacity 0.2s ease",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -33,20 +45,29 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
|||||||
...style,
|
...style,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (thumbnail) {
|
if (thumbnail && !isEncrypted) {
|
||||||
return (
|
return (
|
||||||
<Box style={containerStyle} onClick={onClick}>
|
<Box style={containerStyle} onClick={onClick}>
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<Image
|
<img
|
||||||
src={thumbnail}
|
src={thumbnail}
|
||||||
alt={`Preview of ${file.name}`}
|
alt={`Preview of ${file.name}`}
|
||||||
fit="contain"
|
className={imgClassName}
|
||||||
style={{
|
style={
|
||||||
maxWidth: "100%",
|
imgClassName
|
||||||
maxHeight: "100%",
|
? undefined
|
||||||
width: "auto",
|
: {
|
||||||
height: "auto",
|
maxWidth: "100%",
|
||||||
}}
|
maxHeight: "100%",
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={onImageError}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
{children}
|
{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 (
|
return (
|
||||||
<Box style={containerStyle} onClick={onClick}>
|
<Box style={containerStyle} onClick={onClick}>
|
||||||
<Center
|
<Center
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
backgroundColor: "var(--mantine-color-gray-1)",
|
flexDirection: "column",
|
||||||
borderRadius: "0.25rem",
|
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>
|
</Center>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,17 +1,84 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import JavascriptIcon from "@mui/icons-material/Javascript";
|
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
import type { FileDocVariant } from "@app/components/shared/FileDocIcon";
|
||||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
|
||||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
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;
|
type FileLike = File | StirlingFileStub;
|
||||||
|
|
||||||
/**
|
export function getFileDocVariant(ext: string, mime = ""): FileDocVariant {
|
||||||
* Returns an appropriate file type icon for the provided file.
|
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||||
* - Uses the real file type and extension to decide the icon.
|
if (
|
||||||
* - No any-casts; accepts File or StirlingFileStub.
|
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(
|
export function getFileTypeIcon(
|
||||||
file: FileLike,
|
file: FileLike,
|
||||||
size: number | string = "2rem",
|
size: number | string = "2rem",
|
||||||
@@ -19,29 +86,10 @@ export function getFileTypeIcon(
|
|||||||
const name = (file?.name ?? "").toLowerCase();
|
const name = (file?.name ?? "").toLowerCase();
|
||||||
const mime = (file?.type ?? "").toLowerCase();
|
const mime = (file?.type ?? "").toLowerCase();
|
||||||
const ext = detectFileExtension(name);
|
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 (
|
return (
|
||||||
<InsertDriveFileIcon
|
<FileDocIcon
|
||||||
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
|
variant={getFileDocVariant(ext, mime)}
|
||||||
|
style={{ width: size, height: "auto" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
# RightRail Component
|
|
||||||
|
|
||||||
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
- **Top Section**: Dynamic buttons from tools (empty when none)
|
|
||||||
- **Middle Section**: Grid, Cut, Undo, Redo
|
|
||||||
- **Bottom Section**: Save, Print, Share
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### For Tools (Recommended)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useRightRailButtons } from '../hooks/useRightRailButtons';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
|
|
||||||
function MyTool() {
|
|
||||||
const handleAction = useCallback(() => {
|
|
||||||
// Your action here
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useRightRailButtons([
|
|
||||||
{
|
|
||||||
id: 'my-action',
|
|
||||||
icon: <PlayArrowIcon />,
|
|
||||||
tooltip: 'Execute Action',
|
|
||||||
onClick: handleAction,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <div>My Tool</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Buttons
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
useRightRailButtons([
|
|
||||||
{
|
|
||||||
id: 'primary',
|
|
||||||
icon: <StarIcon />,
|
|
||||||
tooltip: 'Primary Action',
|
|
||||||
order: 1,
|
|
||||||
onClick: handlePrimary,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'secondary',
|
|
||||||
icon: <SettingsIcon />,
|
|
||||||
tooltip: 'Secondary Action',
|
|
||||||
order: 2,
|
|
||||||
onClick: handleSecondary,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conditional Buttons
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
useRightRailButtons([
|
|
||||||
// Always show
|
|
||||||
{
|
|
||||||
id: 'process',
|
|
||||||
icon: <PlayArrowIcon />,
|
|
||||||
tooltip: 'Process',
|
|
||||||
disabled: isProcessing,
|
|
||||||
onClick: handleProcess,
|
|
||||||
},
|
|
||||||
// Only show when condition met
|
|
||||||
...(hasResults ? [{
|
|
||||||
id: 'export',
|
|
||||||
icon: <DownloadIcon />,
|
|
||||||
tooltip: 'Export',
|
|
||||||
onClick: handleExport,
|
|
||||||
}] : []),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### Button Config
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface RightRailButtonWithAction {
|
|
||||||
id: string; // Unique identifier
|
|
||||||
icon?: React.ReactNode; // Icon component (omit when using render)
|
|
||||||
tooltip?: React.ReactNode; // Hover tooltip / description
|
|
||||||
active?: boolean; // Optional active state for highlight
|
|
||||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
|
||||||
order?: number; // Sort order (default: 0)
|
|
||||||
disabled?: boolean; // Disabled state (default: false)
|
|
||||||
visible?: boolean; // Visibility (default: true)
|
|
||||||
render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer
|
|
||||||
onClick?: () => void; // Click handler (optional if using render)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RightRailRenderContext {
|
|
||||||
id: string;
|
|
||||||
disabled: boolean;
|
|
||||||
allButtonsDisabled: boolean;
|
|
||||||
action?: () => void;
|
|
||||||
triggerAction: () => void;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Rendering (Popovers, Multi-button Blocks)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
useRightRailButtons([
|
|
||||||
{
|
|
||||||
id: 'viewer-search',
|
|
||||||
tooltip: t('rightRail.search', 'Search PDF'),
|
|
||||||
render: ({ disabled }) => (
|
|
||||||
<Tooltip content={t('rightRail.search', 'Search PDF')}>
|
|
||||||
<Popover position="left">
|
|
||||||
<Popover.Target>
|
|
||||||
<ActionIcon disabled={disabled}>
|
|
||||||
<SearchIcon />
|
|
||||||
</ActionIcon>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<SearchInterface />
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Built-in Features
|
|
||||||
|
|
||||||
- **Undo/Redo**: Automatically integrates with Page Editor
|
|
||||||
- **Theme Support**: Light/dark mode with CSS variables
|
|
||||||
- **Auto Cleanup**: Buttons unregister when tool unmounts
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
|
|
||||||
- Choose appropriate Material-UI icons
|
|
||||||
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
|
|
||||||
- Use `useCallback` for click handlers to prevent re-registration
|
|
||||||
- Reach for `render` when you need popovers or multi-control groups inside the rail
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
.right-rail {
|
|
||||||
background-color: var(--right-rail-bg);
|
|
||||||
width: 3.5rem;
|
|
||||||
min-width: 3.5rem;
|
|
||||||
max-width: 3.5rem;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
border-left: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-inner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-section {
|
|
||||||
background-color: var(--right-rail-foreground);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-button-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
animation: rightRailButtonReveal 200ms ease forwards;
|
|
||||||
transform-origin: top center;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-tooltip-wrapper {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rightRailButtonReveal {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scaleY(0.6) translateY(-6px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scaleY(1) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-divider {
|
|
||||||
width: 2.75rem;
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--tool-subcategory-rule-color);
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-icon {
|
|
||||||
color: var(--right-rail-icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-icon[data-active="true"] {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-icon[aria-disabled="true"],
|
|
||||||
.right-rail-icon[disabled] {
|
|
||||||
color: var(--right-rail-icon-disabled) !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When all buttons are disabled via context */
|
|
||||||
.right-rail--all-disabled .right-rail-icon {
|
|
||||||
color: var(--right-rail-icon-disabled) !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
|
|
||||||
.right-rail-slot {
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition:
|
|
||||||
max-height 450ms ease-out,
|
|
||||||
opacity 300ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-enter {
|
|
||||||
animation: rightRailGrowDown 450ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-exit {
|
|
||||||
animation: rightRailShrinkUp 450ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-slot.visible {
|
|
||||||
max-height: 40rem; /* increased to fit additional controls + divider */
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rightRailGrowDown {
|
|
||||||
0% {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
max-height: 40rem;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rightRailShrinkUp {
|
|
||||||
0% {
|
|
||||||
max-height: 40rem;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove bottom margin from close icon */
|
|
||||||
.right-rail-slot .right-rail-icon {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline appear/disappear animation for page-number selector button */
|
|
||||||
.right-rail-fade {
|
|
||||||
transition-property: opacity, transform, max-height, visibility;
|
|
||||||
transition-duration: 220ms, 220ms, 220ms, 0s;
|
|
||||||
transition-timing-function: ease, ease, ease, linear;
|
|
||||||
transition-delay: 0s, 0s, 0s, 0s;
|
|
||||||
transform-origin: top center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-fade.enter {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
max-height: 3rem;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-rail-fade.exit {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.85);
|
|
||||||
max-height: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
/* delay visibility change so opacity/max-height can finish */
|
|
||||||
transition-delay: 0s, 0s, 0s, 220ms;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
@@ -43,11 +43,11 @@ const CreateSessionPanel = ({
|
|||||||
<>
|
<>
|
||||||
<div className="quick-access-popout__section">
|
<div className="quick-access-popout__section">
|
||||||
<div className="quick-access-popout__label">
|
<div className="quick-access-popout__label">
|
||||||
{t("quickAccess.selectedFile", "Selected file")}
|
{t("quickAccess.selectedFile", "File")}
|
||||||
</div>
|
</div>
|
||||||
<div className="quick-access-popout__row-title">
|
<div className="quick-access-popout__row-title">
|
||||||
{selectedFiles[0]?.name ||
|
{selectedFiles[0]?.name ||
|
||||||
t("quickAccess.noFile", "No file selected")}
|
t("quickAccess.noFile", "No file loaded")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--search-text-and-icon-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
@@ -26,6 +27,8 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transition: box-shadow 0.2s ease;
|
transition: box-shadow 0.2s ease;
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
color: var(--search-text-and-icon-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
@@ -62,6 +65,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
|
color: var(--search-text-and-icon-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clearButton:hover {
|
.clearButton:hover {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { ActionIcon, ScrollArea, Switch } from "@mantine/core";
|
import { ScrollArea, Switch } from "@mantine/core";
|
||||||
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||||
import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
||||||
@@ -9,10 +8,8 @@ import { ToolId } from "@app/types/toolId";
|
|||||||
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
||||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
|
||||||
import "@app/components/tools/ToolPanel.css";
|
import "@app/components/tools/ToolPanel.css";
|
||||||
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
|
||||||
|
|
||||||
interface FullscreenToolSurfaceProps {
|
interface FullscreenToolSurfaceProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -28,7 +25,6 @@ interface FullscreenToolSurfaceProps {
|
|||||||
onSelect: (id: ToolId) => void;
|
onSelect: (id: ToolId) => void;
|
||||||
onToggleDescriptions: () => void;
|
onToggleDescriptions: () => void;
|
||||||
onExitFullscreenMode: () => void;
|
onExitFullscreenMode: () => void;
|
||||||
toggleLabel: string;
|
|
||||||
geometry: ToolPanelGeometry | null;
|
geometry: ToolPanelGeometry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,47 +38,17 @@ const FullscreenToolSurface = ({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleDescriptions,
|
onToggleDescriptions,
|
||||||
onExitFullscreenMode,
|
onExitFullscreenMode: _onExitFullscreenMode,
|
||||||
toggleLabel,
|
|
||||||
geometry,
|
geometry,
|
||||||
}: FullscreenToolSurfaceProps) => {
|
}: FullscreenToolSurfaceProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isExiting, setIsExiting] = useState(false);
|
|
||||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||||
const isRTL =
|
|
||||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
|
||||||
|
|
||||||
// Enable focus trap when surface is active
|
// Enable focus trap when surface is active
|
||||||
useFocusTrap(surfaceRef, !isExiting);
|
useFocusTrap(surfaceRef, true);
|
||||||
|
|
||||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
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
|
const style = geometry
|
||||||
? {
|
? {
|
||||||
left: `${geometry.left}px`,
|
left: `${geometry.left}px`,
|
||||||
@@ -103,10 +69,7 @@ const FullscreenToolSurface = ({
|
|||||||
)}
|
)}
|
||||||
data-tour="tool-panel"
|
data-tour="tool-panel"
|
||||||
>
|
>
|
||||||
<div
|
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
|
||||||
ref={surfaceRef}
|
|
||||||
className={`tool-panel__fullscreen-surface-inner ${isExiting ? "tool-panel__fullscreen-surface-inner--exiting" : ""}`}
|
|
||||||
>
|
|
||||||
<header className="tool-panel__fullscreen-header">
|
<header className="tool-panel__fullscreen-header">
|
||||||
<div className="tool-panel__fullscreen-brand">
|
<div className="tool-panel__fullscreen-brand">
|
||||||
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
|
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
|
||||||
@@ -115,29 +78,6 @@ const FullscreenToolSurface = ({
|
|||||||
className="tool-panel__fullscreen-brand-text"
|
className="tool-panel__fullscreen-brand-text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div className="tool-panel__fullscreen-controls">
|
<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 */
|
/* CSS Custom Properties for Fullscreen Mode */
|
||||||
.tool-panel__fullscreen-surface-inner {
|
.tool-panel__fullscreen-surface-inner {
|
||||||
--fullscreen-bg-surface-1: color-mix(
|
--fullscreen-bg-surface-1: color-mix(
|
||||||
@@ -109,16 +120,8 @@
|
|||||||
var(--text-primary) 20%,
|
var(--text-primary) 20%,
|
||||||
var(--border-subtle)
|
var(--border-subtle)
|
||||||
);
|
);
|
||||||
--fullscreen-text-icon: color-mix(
|
--fullscreen-text-icon: var(--text-primary);
|
||||||
in srgb,
|
--fullscreen-text-icon-compact: var(--text-primary);
|
||||||
var(--text-primary) 90%,
|
|
||||||
var(--text-muted)
|
|
||||||
);
|
|
||||||
--fullscreen-text-icon-compact: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--text-primary) 88%,
|
|
||||||
var(--text-muted)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel {
|
.tool-panel {
|
||||||
@@ -128,6 +131,49 @@
|
|||||||
max-width 0.3s ease;
|
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 {
|
.tool-panel--fullscreen-active {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
@@ -136,7 +182,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
height: 48px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel__search-row .search-input-container {
|
.tool-panel__search-row .search-input-container {
|
||||||
@@ -469,6 +517,8 @@
|
|||||||
|
|
||||||
.tool-panel__fullscreen-name {
|
.tool-panel__fullscreen-name {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel__fullscreen-description {
|
.tool-panel__fullscreen-description {
|
||||||
@@ -604,7 +654,7 @@
|
|||||||
|
|
||||||
@keyframes tool-panel-fullscreen-slide-in {
|
@keyframes tool-panel-fullscreen-slide-in {
|
||||||
from {
|
from {
|
||||||
transform: translateX(-6%) scaleX(0.85);
|
transform: translateX(6%) scaleX(0.85);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
@@ -619,14 +669,14 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(-6%) scaleX(0.85);
|
transform: translateX(6%) scaleX(0.85);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tool-panel-fullscreen-slide-in-rtl {
|
@keyframes tool-panel-fullscreen-slide-in-rtl {
|
||||||
from {
|
from {
|
||||||
transform: translateX(6%) scaleX(0.85);
|
transform: translateX(-6%) scaleX(0.85);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
@@ -641,7 +691,7 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(6%) scaleX(0.85);
|
transform: translateX(-6%) scaleX(0.85);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
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 ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
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 { ToolId } from "@app/types/toolId";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||||
|
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||||
import { useRightRail } from "@app/contexts/RightRailContext";
|
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||||
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";
|
import "@app/components/tools/ToolPanel.css";
|
||||||
|
|
||||||
// No props needed - component uses context
|
// No props needed - component uses context
|
||||||
@@ -25,7 +28,7 @@ export default function ToolPanel() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -37,14 +40,18 @@ export default function ToolPanel() {
|
|||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
handleToolSelect,
|
handleToolSelect,
|
||||||
|
handleBackToTools,
|
||||||
setPreviewFile,
|
setPreviewFile,
|
||||||
toolPanelMode,
|
toolPanelMode,
|
||||||
setToolPanelMode,
|
setToolPanelMode,
|
||||||
setLeftPanelView,
|
setLeftPanelView,
|
||||||
|
setReaderMode,
|
||||||
|
setSidebarsVisible,
|
||||||
|
sidebarsVisible,
|
||||||
readerMode,
|
readerMode,
|
||||||
} = useToolWorkflow();
|
} = useToolWorkflow();
|
||||||
|
|
||||||
const { setAllRightRailButtonsDisabled } = useRightRail();
|
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||||
const { preferences, updatePreference } = usePreferences();
|
const { preferences, updatePreference } = usePreferences();
|
||||||
|
|
||||||
const isFullscreenMode = toolPanelMode === "fullscreen";
|
const isFullscreenMode = toolPanelMode === "fullscreen";
|
||||||
@@ -54,41 +61,54 @@ export default function ToolPanel() {
|
|||||||
leftPanelView === "toolPicker" &&
|
leftPanelView === "toolPicker" &&
|
||||||
!isMobile &&
|
!isMobile &&
|
||||||
toolPickerVisible;
|
toolPickerVisible;
|
||||||
const isRTL =
|
|
||||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
|
||||||
|
|
||||||
// Disable right rail buttons when fullscreen mode is active
|
// Disable workbench bar buttons when fullscreen mode is active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAllRightRailButtonsDisabled(fullscreenExpanded);
|
setAllButtonsDisabled(fullscreenExpanded);
|
||||||
}, [fullscreenExpanded, setAllRightRailButtonsDisabled]);
|
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||||
|
|
||||||
const fullscreenGeometry = useToolPanelGeometry({
|
const fullscreenGeometry = useToolPanelGeometry({
|
||||||
enabled: fullscreenExpanded,
|
enabled: fullscreenExpanded,
|
||||||
toolPanelRef,
|
toolPanelRef,
|
||||||
quickAccessRef,
|
quickAccessRef,
|
||||||
rightRailRef,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleLabel = isFullscreenMode
|
const handleExpand = () => {
|
||||||
? t("toolPanel.toggle.sidebar", "Switch to sidebar mode")
|
if (readerMode) setReaderMode(false);
|
||||||
: t("toolPanel.toggle.fullscreen", "Switch to fullscreen mode");
|
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||||
|
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||||
const handleModeToggle = () => {
|
|
||||||
const nextMode = isFullscreenMode ? "sidebar" : "fullscreen";
|
|
||||||
setToolPanelMode(nextMode);
|
|
||||||
|
|
||||||
if (nextMode === "fullscreen" && leftPanelView !== "toolPicker") {
|
|
||||||
setLeftPanelView("toolPicker");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const computedWidth = () => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return "100%";
|
return "100%";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPanelVisible) {
|
if (!isPanelVisible) {
|
||||||
return "0";
|
return "3.5rem";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "18.5rem";
|
return "18.5rem";
|
||||||
@@ -109,7 +129,7 @@ export default function ToolPanel() {
|
|||||||
ref={toolPanelRef}
|
ref={toolPanelRef}
|
||||||
data-sidebar="tool-panel"
|
data-sidebar="tool-panel"
|
||||||
data-tour={fullscreenExpanded ? undefined : "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 : ""
|
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -117,21 +137,52 @@ export default function ToolPanel() {
|
|||||||
padding: "0",
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
opacity: isMobile || isPanelVisible ? 1 : 0,
|
opacity: 1,
|
||||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||||
|
<ToolPanelViewerBar />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="tool-panel__search-row"
|
className="tool-panel__search-row"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--tool-panel-search-bg)",
|
backgroundColor: "transparent",
|
||||||
borderBottom: "1px solid var(--tool-panel-search-border-bottom)",
|
borderBottom: "1px solid var(--border-subtle)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolSearch
|
<ToolSearch
|
||||||
@@ -139,29 +190,19 @@ export default function ToolPanel() {
|
|||||||
onChange={setSearchQuery}
|
onChange={setSearchQuery}
|
||||||
toolRegistry={toolRegistry}
|
toolRegistry={toolRegistry}
|
||||||
mode="filter"
|
mode="filter"
|
||||||
|
autoFocus={focusSearch}
|
||||||
/>
|
/>
|
||||||
{!isMobile && leftPanelView === "toolPicker" && (
|
<ActionIcon
|
||||||
<Tooltip
|
variant="outline"
|
||||||
content={toggleLabel}
|
radius="xl"
|
||||||
position="bottom"
|
size="md"
|
||||||
arrow={true}
|
onClick={handleCollapse}
|
||||||
openOnFocus={false}
|
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||||
>
|
className="tool-panel__expand-btn"
|
||||||
<ActionIcon
|
style={{ flexShrink: 0 }}
|
||||||
variant="subtle"
|
>
|
||||||
radius="xl"
|
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||||
style={{ color: "var(--right-rail-icon)" }}
|
</ActionIcon>
|
||||||
onClick={handleModeToggle}
|
|
||||||
aria-label={toggleLabel}
|
|
||||||
className="tool-panel__mode-toggle"
|
|
||||||
>
|
|
||||||
<DoubleArrowIcon
|
|
||||||
fontSize="small"
|
|
||||||
style={{ transform: isRTL ? "scaleX(-1)" : undefined }}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{searchQuery.trim().length > 0 ? (
|
{searchQuery.trim().length > 0 ? (
|
||||||
@@ -185,6 +226,26 @@ export default function ToolPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<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 tools")}
|
||||||
|
styles={{ root: { justifyContent: "flex-start" } }}
|
||||||
|
>
|
||||||
|
{t("toolPanel.backToTools", "Back to tools")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<ScrollArea h="100%">
|
<ScrollArea h="100%">
|
||||||
{selectedToolKey ? (
|
{selectedToolKey ? (
|
||||||
@@ -224,7 +285,6 @@ export default function ToolPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||||
toggleLabel={toggleLabel}
|
|
||||||
geometry={fullscreenGeometry}
|
geometry={fullscreenGeometry}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||||
|
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||||
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
|
import type {
|
||||||
|
WorkbenchBarButtonConfig,
|
||||||
|
WorkbenchBarRenderContext,
|
||||||
|
} from "@app/types/workbenchBar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } = useWorkbenchBar();
|
||||||
|
|
||||||
|
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: WorkbenchBarButtonConfig) => {
|
||||||
|
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: WorkbenchBarRenderContext = {
|
||||||
|
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;
|
isSearching ? filteredTools : emptyFilteredTools;
|
||||||
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
||||||
const headerTextStyle: React.CSSProperties = {
|
const headerTextStyle: React.CSSProperties = {
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.68rem",
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
padding: "0.5rem 0 0.25rem 0.5rem",
|
padding: "1rem 0 0.35rem 0.5rem",
|
||||||
textTransform: "none",
|
textTransform: "uppercase",
|
||||||
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
|
letterSpacing: "0.06em",
|
||||||
opacity: 0.7,
|
color: "var(--text-muted)",
|
||||||
};
|
};
|
||||||
const toTitleCase = (s: string) =>
|
const toTitleCase = (s: string) =>
|
||||||
s.replace(
|
s.replace(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
import CheckIcon from "@mui/icons-material/Check";
|
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 { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||||
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
|
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
|
||||||
import { EXECUTION_STATUS } from "@app/constants/automation";
|
import { EXECUTION_STATUS } from "@app/constants/automation";
|
||||||
@@ -29,7 +29,7 @@ export default function AutomationRun({
|
|||||||
automateOperation,
|
automateOperation,
|
||||||
}: AutomationRunProps) {
|
}: AutomationRunProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
const { regularTools } = useToolRegistry();
|
const { regularTools } = useToolRegistry();
|
||||||
const toolRegistry = regularTools;
|
const toolRegistry = regularTools;
|
||||||
const cleanup = useResourceCleanup();
|
const cleanup = useResourceCleanup();
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
} from "@app/components/tools/compare/compare";
|
} from "@app/components/tools/compare/compare";
|
||||||
import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown";
|
import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
|
import { truncateCenter } from "@app/utils/textUtils";
|
||||||
|
|
||||||
|
const COMPARE_PANE_TITLE_MAX_LEN = 48;
|
||||||
|
|
||||||
// utilities moved to compare.ts
|
// utilities moved to compare.ts
|
||||||
|
|
||||||
@@ -107,9 +110,14 @@ const CompareDocumentPane = ({
|
|||||||
return (
|
return (
|
||||||
<div className="compare-pane">
|
<div className="compare-pane">
|
||||||
<div className="compare-header">
|
<div className="compare-header">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Text fw={600} size="lg">
|
<Text
|
||||||
{title}
|
fw={600}
|
||||||
|
size="lg"
|
||||||
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
|
title={title || undefined}
|
||||||
|
>
|
||||||
|
{title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||||
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ import {
|
|||||||
import { CompareResultData, CompareWorkbenchData } from "@app/types/compare";
|
import { CompareResultData, CompareWorkbenchData } from "@app/types/compare";
|
||||||
import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView";
|
import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView";
|
||||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||||
import { useRightRailButtons } from "@app/hooks/useRightRailButtons";
|
import { useWorkbenchBarButtons } from "@app/hooks/useWorkbenchBarButtons";
|
||||||
import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane";
|
import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane";
|
||||||
import { useComparePagePreviews } from "@app/components/tools/compare/hooks/useComparePagePreviews";
|
import { useComparePagePreviews } from "@app/components/tools/compare/hooks/useComparePagePreviews";
|
||||||
import { useComparePanZoom } from "@app/components/tools/compare/hooks/useComparePanZoom";
|
import { useComparePanZoom } from "@app/components/tools/compare/hooks/useComparePanZoom";
|
||||||
import { useCompareHighlights } from "@app/components/tools/compare/hooks/useCompareHighlights";
|
import { useCompareHighlights } from "@app/components/tools/compare/hooks/useCompareHighlights";
|
||||||
import { useCompareChangeNavigation } from "@app/components/tools/compare/hooks/useCompareChangeNavigation";
|
import { useCompareChangeNavigation } from "@app/components/tools/compare/hooks/useCompareChangeNavigation";
|
||||||
import "@app/components/tools/compare/compareView.css";
|
import "@app/components/tools/compare/compareView.css";
|
||||||
import { useCompareRightRailButtons } from "@app/components/tools/compare/hooks/useCompareRightRailButtons";
|
import { useCompareWorkbenchBarButtons } from "@app/components/tools/compare/hooks/useCompareWorkbenchBarButtons";
|
||||||
import {
|
import {
|
||||||
alert,
|
alert,
|
||||||
updateToast,
|
updateToast,
|
||||||
@@ -209,7 +209,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const rightRailButtons = useCompareRightRailButtons({
|
const workbenchBarButtons = useCompareWorkbenchBarButtons({
|
||||||
layout,
|
layout,
|
||||||
toggleLayout,
|
toggleLayout,
|
||||||
isPanMode,
|
isPanMode,
|
||||||
@@ -230,7 +230,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
|||||||
comparisonScrollRef,
|
comparisonScrollRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
useRightRailButtons(rightRailButtons);
|
useWorkbenchBarButtons(workbenchBarButtons);
|
||||||
|
|
||||||
// Rendering progress toast for very large PDFs
|
// Rendering progress toast for very large PDFs
|
||||||
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
||||||
|
|||||||
@@ -23,10 +23,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-top: 3rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
/* Allow the custom workbench to shrink within flex parents */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 */
|
/* Pixel compare mode */
|
||||||
.compare-pixel-workbench {
|
.compare-pixel-workbench {
|
||||||
padding: 16px 24px 32px;
|
padding: 16px 24px 32px;
|
||||||
|
|||||||
+7
-7
@@ -4,12 +4,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { alert } from "@app/components/toast";
|
import { alert } from "@app/components/toast";
|
||||||
import type { ToastLocation } from "@app/components/toast/types";
|
import type { ToastLocation } from "@app/components/toast/types";
|
||||||
import type { RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
import type { WorkbenchBarButtonWithAction } from "@app/hooks/useWorkbenchBarButtons";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
|
|
||||||
type Pane = "base" | "comparison";
|
type Pane = "base" | "comparison";
|
||||||
|
|
||||||
export interface UseCompareRightRailButtonsOptions {
|
export interface UseCompareWorkbenchBarButtonsOptions {
|
||||||
layout: "side-by-side" | "stacked";
|
layout: "side-by-side" | "stacked";
|
||||||
toggleLayout: () => void;
|
toggleLayout: () => void;
|
||||||
isPanMode: boolean;
|
isPanMode: boolean;
|
||||||
@@ -30,7 +30,7 @@ export interface UseCompareRightRailButtonsOptions {
|
|||||||
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCompareRightRailButtons = ({
|
export const useCompareWorkbenchBarButtons = ({
|
||||||
layout,
|
layout,
|
||||||
toggleLayout,
|
toggleLayout,
|
||||||
isPanMode,
|
isPanMode,
|
||||||
@@ -49,11 +49,11 @@ export const useCompareRightRailButtons = ({
|
|||||||
zoomLimits,
|
zoomLimits,
|
||||||
baseScrollRef,
|
baseScrollRef,
|
||||||
comparisonScrollRef,
|
comparisonScrollRef,
|
||||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
}: UseCompareWorkbenchBarButtonsOptions): WorkbenchBarButtonWithAction[] => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return useMemo<RightRailButtonWithAction[]>(
|
return useMemo<WorkbenchBarButtonWithAction[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "compare-toggle-layout",
|
id: "compare-toggle-layout",
|
||||||
@@ -212,6 +212,6 @@ export const useCompareRightRailButtons = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseCompareRightRailButtonsReturn = ReturnType<
|
export type UseCompareWorkbenchBarButtonsReturn = ReturnType<
|
||||||
typeof useCompareRightRailButtons
|
typeof useCompareWorkbenchBarButtons
|
||||||
>;
|
>;
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||||
import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters";
|
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 CropAreaSelector from "@app/components/tools/crop/CropAreaSelector";
|
||||||
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
|
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
|
||||||
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
|
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 CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
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(() => {
|
const selectedStub = useMemo(() => {
|
||||||
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
|
return fileStubs.length > 0 ? fileStubs[0] : null;
|
||||||
}, [selectedFileStubs]);
|
}, [fileStubs]);
|
||||||
|
|
||||||
// Get the first selected file for PDF processing
|
// Get the first file for PDF processing
|
||||||
const selectedFile = useMemo(() => {
|
const selectedFile = useMemo(() => {
|
||||||
return selectedFiles.length > 0 ? selectedFiles[0] : null;
|
return files.length > 0 ? files[0] : null;
|
||||||
}, [selectedFiles]);
|
}, [files]);
|
||||||
|
|
||||||
// Get thumbnail for the selected file
|
// Get thumbnail for the selected file
|
||||||
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -76,9 +76,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<span className="tool-panel__fullscreen-list-body">
|
<span className="tool-panel__fullscreen-list-body">
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||||
{tool.name}
|
|
||||||
</Text>
|
|
||||||
{tool.versionStatus === "alpha" && (
|
{tool.versionStatus === "alpha" && (
|
||||||
<Badge size="xs" variant="light" color="orange">
|
<Badge size="xs" variant="light" color="orange">
|
||||||
{t("toolPanel.alpha", "Alpha")}
|
{t("toolPanel.alpha", "Alpha")}
|
||||||
|
|||||||
@@ -78,9 +78,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<span className="tool-panel__fullscreen-body">
|
<span className="tool-panel__fullscreen-body">
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||||
{tool.name}
|
|
||||||
</Text>
|
|
||||||
{tool.versionStatus === "alpha" && (
|
{tool.versionStatus === "alpha" && (
|
||||||
<Badge size="xs" variant="light" color="orange">
|
<Badge size="xs" variant="light" color="orange">
|
||||||
{/* we can add more translations for different badges in future, like beta, etc. */}
|
{/* 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 RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||||
import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters";
|
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";
|
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||||
|
|
||||||
interface RotateSettingsProps {
|
interface RotateSettingsProps {
|
||||||
@@ -17,12 +17,12 @@ const RotateSettings = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: RotateSettingsProps) => {
|
}: RotateSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
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(() => {
|
const selectedStub = useMemo(() => {
|
||||||
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
|
return fileStubs.length > 0 ? fileStubs[0] : null;
|
||||||
}, [selectedFileStubs]);
|
}, [fileStubs]);
|
||||||
|
|
||||||
// Get thumbnail for the selected file
|
// Get thumbnail for the selected file
|
||||||
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -59,17 +59,12 @@ const FileStatusIndicator = ({
|
|||||||
|
|
||||||
const getPlaceholder = () => {
|
const getPlaceholder = () => {
|
||||||
if (minFiles === undefined || minFiles === 1) {
|
if (minFiles === undefined || minFiles === 1) {
|
||||||
return t(
|
return t("files.selectFromWorkbench", "Add files to the workbench or ");
|
||||||
"files.selectFromWorkbench",
|
|
||||||
"Select files from the workbench or ",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return t(
|
return t(
|
||||||
"files.selectMultipleFromWorkbench",
|
"files.selectMultipleFromWorkbench",
|
||||||
"Select at least {{count}} files from the workbench or ",
|
"Add at least {{count}} files to the workbench or ",
|
||||||
{
|
{ count: minFiles },
|
||||||
count: minFiles,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -171,14 +166,12 @@ const FileStatusIndicator = ({
|
|||||||
✓{" "}
|
✓{" "}
|
||||||
{selectedFiles.length === 1 ? (
|
{selectedFiles.length === 1 ? (
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
{t("fileSelected", "Selected: {{filename}}", {
|
{t("fileSelected", "{{filename}}", {
|
||||||
filename: selectedFiles[0]?.name,
|
filename: selectedFiles[0]?.name,
|
||||||
})}
|
})}
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
) : (
|
) : (
|
||||||
t("filesSelected", "{{count}} files selected", {
|
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
|
||||||
count: selectedFiles.length,
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ const OperationButton = ({
|
|||||||
"tool.endpointUnavailable",
|
"tool.endpointUnavailable",
|
||||||
"This tool is unavailable on your server.",
|
"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."),
|
noFiles: t("tool.noFiles", "Add a file to get started."),
|
||||||
invalidParams: t("tool.invalidParams", "Fill in the required settings."),
|
invalidParams: t("tool.invalidParams", "Fill in the required settings."),
|
||||||
viewerMode: t(
|
viewerMode: t(
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ function ReviewStepContent<TParams = unknown>({
|
|||||||
|
|
||||||
{onUndo && (
|
{onUndo && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
position="left"
|
||||||
content={t(
|
content={t(
|
||||||
"undoOperationTooltip",
|
"undoOperationTooltip",
|
||||||
"Click to undo the last operation and restore the original files",
|
"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 { useTranslation } from "react-i18next";
|
||||||
import OperationButton, {
|
import OperationButton, {
|
||||||
OperationButtonProps,
|
OperationButtonProps,
|
||||||
@@ -18,8 +18,7 @@ export interface ScopedOperationButtonProps extends OperationButtonProps {
|
|||||||
*
|
*
|
||||||
* - Viewer mode (multiple files loaded): appends "(this file)" to button text and
|
* - Viewer mode (multiple files loaded): appends "(this file)" to button text and
|
||||||
* shows a note naming the exact file that will be processed.
|
* 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.
|
* - N>1 files loaded: appends "(N files)" to button text.
|
||||||
* - File-editor mode with 0 selected files: shows a hint to select files.
|
|
||||||
* - All other cases: no change to button text or layout.
|
* - All other cases: no change to button text or layout.
|
||||||
*/
|
*/
|
||||||
export function ScopedOperationButton({
|
export function ScopedOperationButton({
|
||||||
@@ -30,7 +29,14 @@ export function ScopedOperationButton({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { workbench } = useNavigationState();
|
const { workbench } = useNavigationState();
|
||||||
const { activeFileIndex } = useViewer();
|
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 isViewerMode = workbench === "viewer";
|
||||||
const isFileEditorMode = workbench === "fileEditor";
|
const isFileEditorMode = workbench === "fileEditor";
|
||||||
@@ -56,6 +62,11 @@ export function ScopedOperationButton({
|
|||||||
? allFiles[activeFileIndex]?.name
|
? allFiles[activeFileIndex]?.name
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const pendingCount = fileIds.length - allFiles.length;
|
||||||
|
const isBulkLoading = pendingCount > 1;
|
||||||
|
const loadProgress = isBulkLoading
|
||||||
|
? (allFiles.length / fileIds.length) * 100
|
||||||
|
: 0;
|
||||||
const showSelectFilesHint =
|
const showSelectFilesHint =
|
||||||
!disableScopeHints &&
|
!disableScopeHints &&
|
||||||
isFileEditorMode &&
|
isFileEditorMode &&
|
||||||
@@ -64,7 +75,54 @@ export function ScopedOperationButton({
|
|||||||
|
|
||||||
return (
|
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 && (
|
{viewerFileName && (
|
||||||
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||||
{t("tool.singleFileScope", "Only applying to: {{fileName}}", {
|
{t("tool.singleFileScope", "Only applying to: {{fileName}}", {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
border: 1px solid var(--mantine-color-gray-4);
|
border: 1px solid var(--mantine-color-gray-4);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--right-rail-bg);
|
background: var(--bg-file-manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
.showjs-toolbar {
|
.showjs-toolbar {
|
||||||
|
|||||||
@@ -201,13 +201,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
handleUnlessSpecialClick(e, () => handleClick(id));
|
handleUnlessSpecialClick(e, () => handleClick(id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedStyles = isSelected
|
||||||
|
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
|
||||||
|
: {};
|
||||||
|
|
||||||
const buttonElement = navProps ? (
|
const buttonElement = navProps ? (
|
||||||
// For internal tools with URLs, render Button as an anchor for proper link behavior
|
// For internal tools with URLs, render Button as an anchor for proper link behavior
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="a"
|
||||||
href={navProps.href}
|
href={navProps.href}
|
||||||
onClick={navProps.onClick}
|
onClick={navProps.onClick}
|
||||||
variant={isSelected ? "filled" : "subtle"}
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -219,6 +223,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
color: "var(--tools-text-and-icon-color)",
|
color: "var(--tools-text-and-icon-color)",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
|
...selectedStyles,
|
||||||
},
|
},
|
||||||
label: { overflow: "visible" },
|
label: { overflow: "visible" },
|
||||||
}}
|
}}
|
||||||
@@ -233,7 +238,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={handleExternalClick}
|
onClick={handleExternalClick}
|
||||||
variant={isSelected ? "filled" : "subtle"}
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -245,6 +250,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
color: "var(--tools-text-and-icon-color)",
|
color: "var(--tools-text-and-icon-color)",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
|
...selectedStyles,
|
||||||
},
|
},
|
||||||
label: { overflow: "visible" },
|
label: { overflow: "visible" },
|
||||||
}}
|
}}
|
||||||
@@ -254,7 +260,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
// For unavailable tools, use regular button
|
// For unavailable tools, use regular button
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? "filled" : "subtle"}
|
variant="subtle"
|
||||||
onClick={() => handleClick(id)}
|
onClick={() => handleClick(id)}
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -269,6 +275,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
color: "var(--tools-text-and-icon-color)",
|
color: "var(--tools-text-and-icon-color)",
|
||||||
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
|
...selectedStyles,
|
||||||
},
|
},
|
||||||
label: { overflow: "visible" },
|
label: { overflow: "visible" },
|
||||||
}}
|
}}
|
||||||
@@ -292,7 +299,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
{star}
|
{star}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={tooltipContent}
|
content={tooltipContent}
|
||||||
position="right"
|
position="left"
|
||||||
arrow={true}
|
arrow={true}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -57,13 +57,16 @@
|
|||||||
|
|
||||||
/* Compact tool buttons */
|
/* Compact tool buttons */
|
||||||
.tool-button {
|
.tool-button {
|
||||||
font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */
|
font-size: 0.875rem;
|
||||||
padding-top: 0.375rem;
|
padding-top: 0.5rem;
|
||||||
padding-bottom: 0.375rem;
|
padding-bottom: 0.5rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button .mantine-Button-label {
|
.tool-button .mantine-Button-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.84rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button-icon {
|
.tool-button-icon {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ToolSearchProps {
|
|||||||
selectedToolKey?: string | null;
|
selectedToolKey?: string | null;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
hideIcon?: boolean;
|
hideIcon?: boolean;
|
||||||
|
iconOverride?: React.ReactNode;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ const ToolSearch = ({
|
|||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
placeholder,
|
placeholder,
|
||||||
hideIcon = false,
|
hideIcon = false,
|
||||||
|
iconOverride,
|
||||||
onFocus,
|
onFocus,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
}: ToolSearchProps) => {
|
}: ToolSearchProps) => {
|
||||||
@@ -96,10 +98,12 @@ const ToolSearch = ({
|
|||||||
placeholder || t("toolPicker.searchPlaceholder", "Search tools...")
|
placeholder || t("toolPicker.searchPlaceholder", "Search tools...")
|
||||||
}
|
}
|
||||||
icon={
|
icon={
|
||||||
hideIcon ? undefined : (
|
iconOverride ??
|
||||||
<LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />
|
(hideIcon ? undefined : (
|
||||||
)
|
<LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
iconClickable={!!iconOverride}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ export function CommentsSidebar({
|
|||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: SIDEBAR_WIDTH,
|
width: SIDEBAR_WIDTH,
|
||||||
backgroundColor: "var(--right-rail-bg)",
|
backgroundColor: "var(--bg-file-manager)",
|
||||||
borderLeft: "1px solid var(--border-subtle)",
|
borderLeft: "1px solid var(--border-subtle)",
|
||||||
zIndex: 998,
|
zIndex: 998,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core";
|
import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
@@ -27,7 +28,7 @@ import { useRedaction } from "@app/contexts/RedactionContext";
|
|||||||
import type { RedactionPendingTrackerAPI } from "@app/components/viewer/RedactionPendingTracker";
|
import type { RedactionPendingTrackerAPI } from "@app/components/viewer/RedactionPendingTracker";
|
||||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||||
import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext";
|
import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext";
|
||||||
import { useViewerRightRailButtons } from "@app/components/viewer/useViewerRightRailButtons";
|
import { useViewerWorkbenchBarButtons } from "@app/components/viewer/useViewerWorkbenchBarButtons";
|
||||||
import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay";
|
import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay";
|
||||||
import {
|
import {
|
||||||
RulerOverlay,
|
RulerOverlay,
|
||||||
@@ -138,8 +139,6 @@ export interface EmbedPdfViewerProps {
|
|||||||
setSidebarsVisible: (v: boolean) => void;
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
previewFile?: File | null;
|
previewFile?: File | null;
|
||||||
activeFileIndex?: number;
|
|
||||||
setActiveFileIndex?: (index: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmbedPdfViewerContent = ({
|
const EmbedPdfViewerContent = ({
|
||||||
@@ -147,9 +146,8 @@ const EmbedPdfViewerContent = ({
|
|||||||
setSidebarsVisible: _setSidebarsVisible,
|
setSidebarsVisible: _setSidebarsVisible,
|
||||||
onClose,
|
onClose,
|
||||||
previewFile,
|
previewFile,
|
||||||
activeFileIndex: externalActiveFileIndex,
|
|
||||||
setActiveFileIndex: externalSetActiveFileIndex,
|
|
||||||
}: EmbedPdfViewerProps) => {
|
}: EmbedPdfViewerProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||||
@@ -178,6 +176,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
applyChanges: viewerApplyChanges,
|
applyChanges: viewerApplyChanges,
|
||||||
pdfRenderMode,
|
pdfRenderMode,
|
||||||
cyclePdfRenderMode,
|
cyclePdfRenderMode,
|
||||||
|
setActiveFileIndex: _setActiveFileIndex,
|
||||||
|
activeFileId,
|
||||||
|
setActiveFileId,
|
||||||
} = useViewer();
|
} = useViewer();
|
||||||
|
|
||||||
const scrollState = getScrollState();
|
const scrollState = getScrollState();
|
||||||
@@ -327,63 +328,25 @@ const EmbedPdfViewerContent = ({
|
|||||||
isInAnnotationTool && isPlacementMode && signatureConfig,
|
isInAnnotationTool && isPlacementMode && signatureConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track which file tab is active
|
// Determine which file to display — use activeFileId (stable) not activeFileIndex (shifts on removal)
|
||||||
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
|
|
||||||
const currentFile = React.useMemo(() => {
|
const currentFile = React.useMemo(() => {
|
||||||
if (previewFile) {
|
if (previewFile) {
|
||||||
return previewFile;
|
return previewFile;
|
||||||
} else if (activeFiles.length > 0) {
|
} 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;
|
return null;
|
||||||
}, [previewFile, activeFiles, activeFileIndex]);
|
}, [previewFile, activeFiles, activeFileId]);
|
||||||
|
|
||||||
// Get file with URL for rendering
|
// Stable id — avoids blob URL churn when FileContext recreates file objects each render.
|
||||||
const fileWithUrl = useFileWithUrl(currentFile);
|
const currentFileStableId =
|
||||||
|
currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null;
|
||||||
|
const fileWithUrl = useFileWithUrl(currentFile, currentFileStableId);
|
||||||
|
|
||||||
// Determine the effective file to display
|
// Determine the effective file to display
|
||||||
const effectiveFile = React.useMemo(() => {
|
const effectiveFile = React.useMemo(() => {
|
||||||
@@ -703,7 +666,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
|
|
||||||
// Step 3: Create StirlingFiles and stubs for version history
|
// Step 3: Create StirlingFiles and stubs for version history
|
||||||
// Only consume the current file, not all active files
|
// 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");
|
if (!currentFileId) throw new Error("Current file ID not found");
|
||||||
|
|
||||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
@@ -739,7 +702,6 @@ const EmbedPdfViewerContent = ({
|
|||||||
}, [
|
}, [
|
||||||
currentFile,
|
currentFile,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
activeFileIndex,
|
|
||||||
exportActions,
|
exportActions,
|
||||||
actions,
|
actions,
|
||||||
selectors,
|
selectors,
|
||||||
@@ -773,7 +735,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get current file info for creating the updated version
|
// 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");
|
if (!currentFileId) throw new Error("Current file ID not found");
|
||||||
|
|
||||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
@@ -811,7 +773,6 @@ const EmbedPdfViewerContent = ({
|
|||||||
[
|
[
|
||||||
currentFile,
|
currentFile,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
activeFileIndex,
|
|
||||||
actions,
|
actions,
|
||||||
selectors,
|
selectors,
|
||||||
activeFileIds.length,
|
activeFileIds.length,
|
||||||
@@ -847,7 +808,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
type: "application/pdf",
|
type: "application/pdf",
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
const currentFileId = currentFileStableId;
|
||||||
if (!currentFileId) throw new Error("Current file ID not found");
|
if (!currentFileId) throw new Error("Current file ID not found");
|
||||||
|
|
||||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
@@ -877,7 +838,6 @@ const EmbedPdfViewerContent = ({
|
|||||||
[
|
[
|
||||||
currentFile,
|
currentFile,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
activeFileIndex,
|
|
||||||
actions,
|
actions,
|
||||||
selectors,
|
selectors,
|
||||||
activeFileIds.length,
|
activeFileIds.length,
|
||||||
@@ -916,7 +876,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
const file = new File([blob], filename, { type: "application/pdf" });
|
const file = new File([blob], filename, { type: "application/pdf" });
|
||||||
|
|
||||||
// Create StirlingFiles and stubs for version history
|
// 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");
|
if (!currentFileId) throw new Error("Current file ID not found");
|
||||||
|
|
||||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
@@ -949,7 +909,6 @@ const EmbedPdfViewerContent = ({
|
|||||||
redactionsApplied,
|
redactionsApplied,
|
||||||
currentFile,
|
currentFile,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
activeFileIndex,
|
|
||||||
activeFileIds.length,
|
activeFileIds.length,
|
||||||
exportActions,
|
exportActions,
|
||||||
actions,
|
actions,
|
||||||
@@ -1123,8 +1082,8 @@ const EmbedPdfViewerContent = ({
|
|||||||
};
|
};
|
||||||
}, [effectiveFile]);
|
}, [effectiveFile]);
|
||||||
|
|
||||||
// Register viewer right-rail buttons
|
// Register workbench bar buttons for the viewer
|
||||||
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
useViewerWorkbenchBarButtons(isRulerActive, setIsRulerActive);
|
||||||
|
|
||||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||||
// In normal viewer mode, this uses PDFium WASM (frontend-only).
|
// In normal viewer mode, this uses PDFium WASM (frontend-only).
|
||||||
@@ -1157,7 +1116,11 @@ const EmbedPdfViewerContent = ({
|
|||||||
// the effect re-fires before the async fetch completes.
|
// 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);
|
console.log("[FormFill] Fetching form fields for:", currentFileId);
|
||||||
fetchFormFields(currentFile, currentFileId ?? undefined);
|
fetchFormFields(currentFile, currentFileId ?? undefined);
|
||||||
}
|
}
|
||||||
@@ -1219,7 +1182,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
<Center style={{ flex: 1 }}>
|
<Center style={{ flex: 1 }}>
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<LockIcon style={{ fontSize: 48, opacity: 0.5 }} />
|
<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
|
<Button
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1228,7 +1196,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Unlock
|
{t("encryptedPdfUnlock.viewerUnlock", "Unlock")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -1329,7 +1297,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
<ThumbnailSidebar
|
<ThumbnailSidebar
|
||||||
visible={isThumbnailSidebarVisible}
|
visible={isThumbnailSidebarVisible}
|
||||||
onToggle={toggleThumbnailSidebar}
|
onToggle={toggleThumbnailSidebar}
|
||||||
activeFileIndex={activeFileIndex}
|
activeFileId={activeFileId}
|
||||||
/>
|
/>
|
||||||
<BookmarkSidebar
|
<BookmarkSidebar
|
||||||
visible={isBookmarkSidebarVisible}
|
visible={isBookmarkSidebarVisible}
|
||||||
|
|||||||
@@ -162,7 +162,10 @@ export function LocalEmbedPDF({
|
|||||||
});
|
});
|
||||||
}, [config?.enableLogin]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (file) {
|
if (file) {
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
@@ -171,7 +174,17 @@ export function LocalEmbedPDF({
|
|||||||
} else if (url) {
|
} else if (url) {
|
||||||
setPdfUrl(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
|
// Create plugins configuration
|
||||||
const plugins = useMemo(() => {
|
const plugins = useMemo(() => {
|
||||||
@@ -183,17 +196,6 @@ export function LocalEmbedPDF({
|
|||||||
);
|
);
|
||||||
const viewportGap = rootFontSize * 3.5;
|
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 [
|
return [
|
||||||
createPluginRegistration(DocumentManagerPluginPackage, {
|
createPluginRegistration(DocumentManagerPluginPackage, {
|
||||||
initialDocuments: [
|
initialDocuments: [
|
||||||
@@ -290,7 +292,7 @@ export function LocalEmbedPDF({
|
|||||||
// Register print plugin for printing PDFs
|
// Register print plugin for printing PDFs
|
||||||
createPluginRegistration(PrintPluginPackage),
|
createPluginRegistration(PrintPluginPackage),
|
||||||
];
|
];
|
||||||
}, [pdfUrl, enableAnnotations, fileName, file, url]);
|
}, [pdfUrl, enableAnnotations, exportFileName]);
|
||||||
|
|
||||||
// Initialize the engine with the React hook - use local WASM for offline support
|
// Initialize the engine with the React hook - use local WASM for offline support
|
||||||
const { engine, isLoading, error } = usePdfiumEngine({
|
const { engine, isLoading, error } = usePdfiumEngine({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ArticleIcon from "@mui/icons-material/Article";
|
|||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
|
|
||||||
import { useFileState } from "@app/contexts/FileContext";
|
import { useFileState } from "@app/contexts/FileContext";
|
||||||
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import {
|
import {
|
||||||
detectFileExtension,
|
detectFileExtension,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
import { CONVERSION_MATRIX } from "@app/constants/convertConstants";
|
import { CONVERSION_MATRIX } from "@app/constants/convertConstants";
|
||||||
|
|
||||||
import { NonPdfBanner } from "@app/components/viewer/nonpdf/NonPdfBanner";
|
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 { ImageViewer } from "@app/components/viewer/nonpdf/ImageViewer";
|
||||||
import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer";
|
import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer";
|
||||||
import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer";
|
import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer";
|
||||||
@@ -24,8 +24,6 @@ export interface ViewerProps {
|
|||||||
setSidebarsVisible: (v: boolean) => void;
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
previewFile?: File | null;
|
previewFile?: File | null;
|
||||||
activeFileIndex?: number;
|
|
||||||
setActiveFileIndex?: (index: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NonPdfViewerProps extends ViewerProps {
|
export interface NonPdfViewerProps extends ViewerProps {
|
||||||
@@ -34,10 +32,6 @@ export interface NonPdfViewerProps extends ViewerProps {
|
|||||||
|
|
||||||
export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
||||||
const fileType = useMemo(() => detectNonPdfFileType(file), [file]);
|
const fileType = useMemo(() => detectNonPdfFileType(file), [file]);
|
||||||
const meta = useMemo(
|
|
||||||
() => getFileTypeMeta(fileType, file.name),
|
|
||||||
[fileType, file.name],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { handleToolSelect, toolAvailability } = useToolWorkflow();
|
const { handleToolSelect, toolAvailability } = useToolWorkflow();
|
||||||
|
|
||||||
@@ -112,7 +106,6 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NonPdfBanner
|
<NonPdfBanner
|
||||||
meta={meta}
|
|
||||||
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
|
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
@@ -134,7 +127,7 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
|||||||
export function NonPdfViewerWrapper(props: ViewerProps) {
|
export function NonPdfViewerWrapper(props: ViewerProps) {
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
const activeFiles = selectors.getFiles();
|
const activeFiles = selectors.getFiles();
|
||||||
const activeFileIndex = props.activeFileIndex ?? 0;
|
const { activeFileIndex } = useViewer();
|
||||||
|
|
||||||
const file =
|
const file =
|
||||||
props.previewFile ?? activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
|
props.previewFile ?? activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
|
||||||
@@ -143,7 +136,7 @@ export function NonPdfViewerWrapper(props: ViewerProps) {
|
|||||||
return (
|
return (
|
||||||
<Center style={{ flex: 1 }}>
|
<Center style={{ flex: 1 }}>
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
No file selected
|
No file loaded
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
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 DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||||
import WbSunnyIcon from "@mui/icons-material/WbSunny";
|
import WbSunnyIcon from "@mui/icons-material/WbSunny";
|
||||||
import WbTwilightIcon from "@mui/icons-material/WbTwilight";
|
import WbTwilightIcon from "@mui/icons-material/WbTwilight";
|
||||||
|
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||||
|
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||||
|
|
||||||
interface PdfViewerToolbarProps {
|
interface PdfViewerToolbarProps {
|
||||||
// Page navigation props (placeholders for now)
|
// Page navigation props (placeholders for now)
|
||||||
@@ -291,31 +300,48 @@ export function PdfViewerToolbar({
|
|||||||
|
|
||||||
{/* Zoom Controls */}
|
{/* Zoom Controls */}
|
||||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||||
<Button
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="blue"
|
color="blue"
|
||||||
size="md"
|
radius="md"
|
||||||
radius="xl"
|
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
style={{ minWidth: "2rem", padding: 0 }}
|
aria-label={t("viewer.zoomOut", "Zoom out")}
|
||||||
title={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}%
|
{displayZoomPercent}%
|
||||||
</span>
|
</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>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { PrivateContent } from "@app/components/shared/PrivateContent";
|
|||||||
interface ThumbnailSidebarProps {
|
interface ThumbnailSidebarProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
activeFileIndex?: number;
|
activeFileId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThumbnailSidebar({
|
export function ThumbnailSidebar({
|
||||||
visible,
|
visible,
|
||||||
onToggle: _onToggle,
|
onToggle: _onToggle,
|
||||||
activeFileIndex,
|
activeFileId,
|
||||||
}: ThumbnailSidebarProps) {
|
}: ThumbnailSidebarProps) {
|
||||||
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
|
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
|
||||||
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
|
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
|
||||||
@@ -29,7 +29,7 @@ export function ThumbnailSidebar({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setThumbnails({});
|
setThumbnails({});
|
||||||
}, [activeFileIndex]);
|
}, [activeFileId]);
|
||||||
|
|
||||||
// Keep a ref to thumbnails for cleanup on unmount
|
// Keep a ref to thumbnails for cleanup on unmount
|
||||||
const thumbnailsRef = useRef(thumbnails);
|
const thumbnailsRef = useRef(thumbnails);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
type ViewerProps,
|
type ViewerProps,
|
||||||
} from "@app/components/viewer/NonPdfViewer";
|
} from "@app/components/viewer/NonPdfViewer";
|
||||||
import { useFileState } from "@app/contexts/FileContext";
|
import { useFileState } from "@app/contexts/FileContext";
|
||||||
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
|
import { isStirlingFile } from "@app/types/fileContext";
|
||||||
import { isPdfFile } from "@app/utils/fileUtils";
|
import { isPdfFile } from "@app/utils/fileUtils";
|
||||||
|
|
||||||
export type { ViewerProps };
|
export type { ViewerProps };
|
||||||
@@ -12,13 +14,16 @@ export type { ViewerProps };
|
|||||||
const Viewer = (props: ViewerProps) => {
|
const Viewer = (props: ViewerProps) => {
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
const activeFiles = selectors.getFiles();
|
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(() => {
|
const activeFile = useMemo(() => {
|
||||||
if (props.previewFile) return props.previewFile;
|
if (props.previewFile) return props.previewFile;
|
||||||
return activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
|
const byId = activeFileId
|
||||||
}, [props.previewFile, activeFiles, activeFileIndex]);
|
? 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
|
// Route to the appropriate viewer based on file type
|
||||||
if (activeFile && !isPdfFile(activeFile)) {
|
if (activeFile && !isPdfFile(activeFile)) {
|
||||||
|
|||||||
+13
-28
@@ -12,9 +12,7 @@ import {
|
|||||||
useNavigationGuard,
|
useNavigationGuard,
|
||||||
useNavigationActions,
|
useNavigationActions,
|
||||||
} from "@app/contexts/NavigationContext";
|
} from "@app/contexts/NavigationContext";
|
||||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
|
|
||||||
import { useRedactionMode, useRedaction } from "@app/contexts/RedactionContext";
|
import { useRedactionMode, useRedaction } from "@app/contexts/RedactionContext";
|
||||||
import {
|
import {
|
||||||
defaultParameters,
|
defaultParameters,
|
||||||
@@ -32,10 +30,7 @@ export default function ViewerAnnotationControls({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: ViewerAnnotationControlsProps) {
|
}: ViewerAnnotationControlsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { sidebarRefs } = useSidebarContext();
|
|
||||||
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
|
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
|
||||||
const { position: tooltipPosition, offset: tooltipOffset } =
|
|
||||||
useRightRailTooltipSide(sidebarRefs);
|
|
||||||
|
|
||||||
// Viewer context for PDF controls - safely handle when not available
|
// Viewer context for PDF controls - safely handle when not available
|
||||||
const viewerContext = React.useContext(ViewerContext);
|
const viewerContext = React.useContext(ViewerContext);
|
||||||
@@ -129,31 +124,25 @@ export default function ViewerAnnotationControls({
|
|||||||
// Handle redaction mode toggle
|
// Handle redaction mode toggle
|
||||||
const handleRedactionToggle = async () => {
|
const handleRedactionToggle = async () => {
|
||||||
if (isRedactMode) {
|
if (isRedactMode) {
|
||||||
// Exit redaction mode
|
|
||||||
exitRedactionMode();
|
exitRedactionMode();
|
||||||
} else {
|
} else {
|
||||||
// Check for unsaved annotation changes
|
|
||||||
const hasAnnotationChanges = historyApiRef?.current?.canUndo() ?? false;
|
const hasAnnotationChanges = historyApiRef?.current?.canUndo() ?? false;
|
||||||
|
|
||||||
const enterRedactionMode = async () => {
|
const enterRedactionMode = async () => {
|
||||||
await saveAnnotationsIfNeeded();
|
await saveAnnotationsIfNeeded();
|
||||||
|
|
||||||
// Set redaction config to manual mode when opening from viewer
|
|
||||||
const manualConfig: RedactParameters = {
|
const manualConfig: RedactParameters = {
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
};
|
};
|
||||||
setRedactionConfig(manualConfig);
|
setRedactionConfig(manualConfig);
|
||||||
|
|
||||||
// Set tool and keep viewer workbench
|
|
||||||
navActions.setToolAndWorkbench("redact", "viewer");
|
navActions.setToolAndWorkbench("redact", "viewer");
|
||||||
|
|
||||||
// Ensure sidebars are visible and open tool content
|
|
||||||
setSidebarsVisible(true);
|
setSidebarsVisible(true);
|
||||||
setLeftPanelView("toolContent");
|
setLeftPanelView("toolContent");
|
||||||
|
|
||||||
setRedactionMode(true);
|
setRedactionMode(true);
|
||||||
// Activate unified redact mode after a short delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const currentType = redactionApiRef.current?.getActiveType?.();
|
const currentType = redactionApiRef.current?.getActiveType?.();
|
||||||
if (currentType !== RedactionMode.Redact) {
|
if (currentType !== RedactionMode.Redact) {
|
||||||
@@ -171,7 +160,6 @@ export default function ViewerAnnotationControls({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleAnnotationsVisibility = useCallback(() => {
|
const handleToggleAnnotationsVisibility = useCallback(() => {
|
||||||
// When going from visible → hidden with unsaved changes, prompt to save first
|
|
||||||
if (!annotationsHidden && hasUnsavedChanges) {
|
if (!annotationsHidden && hasUnsavedChanges) {
|
||||||
requestNavigation(() => viewerContext?.toggleAnnotationsVisibility());
|
requestNavigation(() => viewerContext?.toggleAnnotationsVisibility());
|
||||||
} else {
|
} else {
|
||||||
@@ -179,7 +167,6 @@ export default function ViewerAnnotationControls({
|
|||||||
}
|
}
|
||||||
}, [annotationsHidden, hasUnsavedChanges, requestNavigation, viewerContext]);
|
}, [annotationsHidden, hasUnsavedChanges, requestNavigation, viewerContext]);
|
||||||
|
|
||||||
// Don't show any annotation controls in sign mode
|
|
||||||
// NOTE: This early return is placed AFTER all hooks to satisfy React's rules of hooks
|
// NOTE: This early return is placed AFTER all hooks to satisfy React's rules of hooks
|
||||||
if (isSignMode) {
|
if (isSignMode) {
|
||||||
return null;
|
return null;
|
||||||
@@ -187,15 +174,14 @@ export default function ViewerAnnotationControls({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Redaction Mode Toggle */}
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
isRedactMode
|
isRedactMode
|
||||||
? t("rightRail.exitRedaction", "Exit Redaction Mode")
|
? t("workbenchBar.exitRedaction", "Exit Redaction Mode")
|
||||||
: t("rightRail.redact", "Redact")
|
: t("workbenchBar.redact", "Redact")
|
||||||
}
|
}
|
||||||
position={tooltipPosition}
|
position="bottom"
|
||||||
offset={tooltipOffset}
|
offset={16}
|
||||||
arrow
|
arrow
|
||||||
portalTarget={document.body}
|
portalTarget={document.body}
|
||||||
>
|
>
|
||||||
@@ -203,26 +189,25 @@ export default function ViewerAnnotationControls({
|
|||||||
variant={isRedactMode ? "filled" : "subtle"}
|
variant={isRedactMode ? "filled" : "subtle"}
|
||||||
color={isRedactMode ? "blue" : undefined}
|
color={isRedactMode ? "blue" : undefined}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="workbench-bar-action-icon"
|
||||||
onClick={handleRedactionToggle}
|
onClick={handleRedactionToggle}
|
||||||
disabled={disabled || currentView !== "viewer"}
|
disabled={disabled || currentView !== "viewer"}
|
||||||
>
|
>
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
icon="scan-delete-rounded"
|
icon="scan-delete-rounded"
|
||||||
width="1.5rem"
|
width="1.25rem"
|
||||||
height="1.5rem"
|
height="1.25rem"
|
||||||
/>
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Annotation Visibility Toggle */}
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={t(
|
content={t(
|
||||||
"rightRail.toggleAnnotations",
|
"workbenchBar.toggleAnnotations",
|
||||||
"Toggle Annotations Visibility",
|
"Toggle Annotations Visibility",
|
||||||
)}
|
)}
|
||||||
position={tooltipPosition}
|
position="bottom"
|
||||||
offset={tooltipOffset}
|
offset={16}
|
||||||
arrow
|
arrow
|
||||||
portalTarget={document.body}
|
portalTarget={document.body}
|
||||||
>
|
>
|
||||||
@@ -230,7 +215,7 @@ export default function ViewerAnnotationControls({
|
|||||||
variant={annotationsHidden ? "filled" : "subtle"}
|
variant={annotationsHidden ? "filled" : "subtle"}
|
||||||
color={annotationsHidden ? "blue" : undefined}
|
color={annotationsHidden ? "blue" : undefined}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="workbench-bar-action-icon"
|
||||||
onClick={handleToggleAnnotationsVisibility}
|
onClick={handleToggleAnnotationsVisibility}
|
||||||
disabled={
|
disabled={
|
||||||
disabled ||
|
disabled ||
|
||||||
@@ -247,8 +232,8 @@ export default function ViewerAnnotationControls({
|
|||||||
? "visibility"
|
? "visibility"
|
||||||
: "preview-off-rounded"
|
: "preview-off-rounded"
|
||||||
}
|
}
|
||||||
width="1.5rem"
|
width="1.25rem"
|
||||||
height="1.5rem"
|
height="1.25rem"
|
||||||
/>
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -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 PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { FileTypeMeta } from "@app/components/viewer/nonpdf/types";
|
|
||||||
|
|
||||||
interface NonPdfBannerProps {
|
interface NonPdfBannerProps {
|
||||||
meta: FileTypeMeta;
|
|
||||||
onConvertToPdf?: () => void;
|
onConvertToPdf?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) {
|
export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!onConvertToPdf) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Button
|
||||||
gap="xs"
|
size="xs"
|
||||||
wrap="nowrap"
|
variant="light"
|
||||||
align="center"
|
color="orange"
|
||||||
|
leftSection={<PictureAsPdfIcon style={{ fontSize: "0.9rem" }} />}
|
||||||
|
onClick={onConvertToPdf}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
pointerEvents: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Badge
|
{t("viewer.nonPdf.convertToPdf")}
|
||||||
variant="default"
|
</Button>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<Paper
|
<Paper
|
||||||
radius={0}
|
radius={0}
|
||||||
p="xs"
|
px="sm"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
minHeight: 44,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="md" align="center">
|
<Group gap="md" align="center">
|
||||||
|
|||||||
+58
-60
@@ -4,15 +4,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { supportedLanguages } from "@app/i18n";
|
import { supportedLanguages } from "@app/i18n";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import {
|
import {
|
||||||
useRightRailButtons,
|
useWorkbenchBarButtons,
|
||||||
RightRailButtonWithAction,
|
WorkbenchBarButtonWithAction,
|
||||||
} from "@app/hooks/useRightRailButtons";
|
} from "@app/hooks/useWorkbenchBarButtons";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
import { SearchInterface } from "@app/components/viewer/SearchInterface";
|
import { SearchInterface } from "@app/components/viewer/SearchInterface";
|
||||||
import ViewerAnnotationControls from "@app/components/shared/rightRail/ViewerAnnotationControls";
|
import ViewerAnnotationControls from "@app/components/viewer/ViewerAnnotationControls";
|
||||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||||
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
|
import { useWorkbenchBarTooltipSide } from "@app/hooks/useWorkbenchBarTooltipSide";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import {
|
import {
|
||||||
useNavigationState,
|
useNavigationState,
|
||||||
@@ -27,7 +27,7 @@ import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
|||||||
import StopIcon from "@mui/icons-material/Stop";
|
import StopIcon from "@mui/icons-material/Stop";
|
||||||
import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud";
|
import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud";
|
||||||
|
|
||||||
export function useViewerRightRailButtons(
|
export function useViewerWorkbenchBarButtons(
|
||||||
isRulerActive?: boolean,
|
isRulerActive?: boolean,
|
||||||
setIsRulerActive?: (v: boolean) => void,
|
setIsRulerActive?: (v: boolean) => void,
|
||||||
) {
|
) {
|
||||||
@@ -46,7 +46,7 @@ export function useViewerRightRailButtons(
|
|||||||
} = viewer;
|
} = viewer;
|
||||||
const [isPanning, setIsPanning] = useState<boolean>(false);
|
const [isPanning, setIsPanning] = useState<boolean>(false);
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { position: tooltipPosition } = useRightRailTooltipSide(
|
const { position: tooltipPosition } = useWorkbenchBarTooltipSide(
|
||||||
sidebarRefs,
|
sidebarRefs,
|
||||||
12,
|
12,
|
||||||
);
|
);
|
||||||
@@ -105,28 +105,27 @@ export function useViewerRightRailButtons(
|
|||||||
return () => window.removeEventListener("popstate", handlePopState);
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
}, [isAnnotationsPath]);
|
}, [isAnnotationsPath]);
|
||||||
|
|
||||||
const searchLabel = t("rightRail.search", "Search PDF");
|
const searchLabel = t("workbenchBar.search", "Search PDF");
|
||||||
const panLabel = t("rightRail.panMode", "Pan Mode");
|
const panLabel = t("workbenchBar.panMode", "Pan Mode");
|
||||||
const applyRedactionsLabel = t(
|
const applyRedactionsLabel = t(
|
||||||
"rightRail.applyRedactionsFirst",
|
"workbenchBar.applyRedactionsFirst",
|
||||||
"Apply redactions first",
|
"Apply redactions first",
|
||||||
);
|
);
|
||||||
const rotateLeftLabel = t("rightRail.rotateLeft", "Rotate Left");
|
const rotateLeftLabel = t("workbenchBar.rotateLeft", "Rotate Left");
|
||||||
const rotateRightLabel = t("rightRail.rotateRight", "Rotate Right");
|
const rotateRightLabel = t("workbenchBar.rotateRight", "Rotate Right");
|
||||||
const sidebarLabel = t("rightRail.toggleSidebar", "Toggle Sidebar");
|
const sidebarLabel = t("workbenchBar.toggleSidebar", "Toggle Sidebar");
|
||||||
const bookmarkLabel = t("rightRail.toggleBookmarks", "Toggle Bookmarks");
|
const bookmarkLabel = t("workbenchBar.toggleBookmarks", "Toggle Bookmarks");
|
||||||
const attachmentLabel = t(
|
const attachmentLabel = t(
|
||||||
"rightRail.toggleAttachments",
|
"workbenchBar.toggleAttachments",
|
||||||
"Toggle Attachments",
|
"Toggle Attachments",
|
||||||
);
|
);
|
||||||
const layersLabel = t("rightRail.toggleLayers", "Toggle Layers");
|
const layersLabel = t("workbenchBar.toggleLayers", "Toggle Layers");
|
||||||
const commentsLabel = t("rightRail.toggleComments", "Comments");
|
const commentsLabel = t("workbenchBar.toggleComments", "Comments");
|
||||||
const printLabel = t("rightRail.print", "Print PDF");
|
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
|
||||||
const annotationsLabel = t("rightRail.annotations", "Annotations");
|
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
|
||||||
const formFillLabel = t("rightRail.formFill", "Fill Form");
|
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
|
||||||
const rulerLabel = t("rightRail.ruler", "Ruler / Measure");
|
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
|
||||||
const readAloudLabel = t("rightRail.readAloud", "Read Aloud");
|
const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed");
|
||||||
const readAloudSpeedLabel = t("rightRail.readAloudSpeed", "Speed");
|
|
||||||
|
|
||||||
const isFormFillActive = (selectedTool as string) === "formFill";
|
const isFormFillActive = (selectedTool as string) === "formFill";
|
||||||
|
|
||||||
@@ -150,8 +149,8 @@ export function useViewerRightRailButtons(
|
|||||||
const shouldShowLanguageSelector =
|
const shouldShowLanguageSelector =
|
||||||
supportedLanguageCodes.size === 0 || filteredLanguages.length > 1;
|
supportedLanguageCodes.size === 0 || filteredLanguages.length > 1;
|
||||||
|
|
||||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
const viewerButtons = useMemo<WorkbenchBarButtonWithAction[]>(() => {
|
||||||
const buttons: RightRailButtonWithAction[] = [
|
const buttons: WorkbenchBarButtonWithAction[] = [
|
||||||
{
|
{
|
||||||
id: "viewer-search",
|
id: "viewer-search",
|
||||||
tooltip: searchLabel,
|
tooltip: searchLabel,
|
||||||
@@ -179,12 +178,12 @@ export function useViewerRightRailButtons(
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="workbench-bar-action-icon"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={searchLabel}
|
aria-label={searchLabel}
|
||||||
onClick={viewer.searchInterfaceActions.toggle}
|
onClick={viewer.searchInterfaceActions.toggle}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="search" width="1.25rem" height="1.25rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
@@ -203,7 +202,7 @@ export function useViewerRightRailButtons(
|
|||||||
{
|
{
|
||||||
id: "viewer-pan-mode",
|
id: "viewer-pan-mode",
|
||||||
icon: (
|
icon: (
|
||||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="pan-tool-rounded" width="1.25rem" height="1.25rem" />
|
||||||
),
|
),
|
||||||
tooltip:
|
tooltip:
|
||||||
!isPanning && pendingCount > 0 && redactionActiveType !== null
|
!isPanning && pendingCount > 0 && redactionActiveType !== null
|
||||||
@@ -220,12 +219,16 @@ export function useViewerRightRailButtons(
|
|||||||
!isPanning && pendingCount > 0 && redactionActiveType !== null,
|
!isPanning && pendingCount > 0 && redactionActiveType !== null,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
viewer.panActions.togglePan();
|
viewer.panActions.togglePan();
|
||||||
setIsPanning((prev) => !prev);
|
setIsPanning((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (next && isRulerActive) setIsRulerActive?.(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "viewer-ruler",
|
id: "viewer-ruler",
|
||||||
icon: <StraightenIcon sx={{ fontSize: "1.5rem" }} />,
|
icon: <StraightenIcon sx={{ fontSize: "1.25rem" }} />,
|
||||||
tooltip: rulerLabel,
|
tooltip: rulerLabel,
|
||||||
ariaLabel: rulerLabel,
|
ariaLabel: rulerLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
@@ -236,12 +239,13 @@ export function useViewerRightRailButtons(
|
|||||||
setIsRulerActive?.(next);
|
setIsRulerActive?.(next);
|
||||||
if (next && isPanning) {
|
if (next && isPanning) {
|
||||||
viewer.panActions.disablePan();
|
viewer.panActions.disablePan();
|
||||||
|
setIsPanning(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "viewer-rotate-left",
|
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,
|
tooltip: rotateLeftLabel,
|
||||||
ariaLabel: rotateLeftLabel,
|
ariaLabel: rotateLeftLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
@@ -252,7 +256,9 @@ export function useViewerRightRailButtons(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "viewer-rotate-right",
|
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,
|
tooltip: rotateRightLabel,
|
||||||
ariaLabel: rotateRightLabel,
|
ariaLabel: rotateRightLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
@@ -263,7 +269,7 @@ export function useViewerRightRailButtons(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "viewer-toggle-sidebar",
|
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,
|
tooltip: sidebarLabel,
|
||||||
ariaLabel: sidebarLabel,
|
ariaLabel: sidebarLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
@@ -278,8 +284,8 @@ export function useViewerRightRailButtons(
|
|||||||
icon: (
|
icon: (
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
icon="bookmark-add-rounded"
|
icon="bookmark-add-rounded"
|
||||||
width="1.5rem"
|
width="1.25rem"
|
||||||
height="1.5rem"
|
height="1.25rem"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
tooltip: bookmarkLabel,
|
tooltip: bookmarkLabel,
|
||||||
@@ -294,7 +300,11 @@ export function useViewerRightRailButtons(
|
|||||||
{
|
{
|
||||||
id: "viewer-toggle-attachments",
|
id: "viewer-toggle-attachments",
|
||||||
icon: (
|
icon: (
|
||||||
<LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />
|
<LocalIcon
|
||||||
|
icon="attachment-rounded"
|
||||||
|
width="1.25rem"
|
||||||
|
height="1.25rem"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
tooltip: attachmentLabel,
|
tooltip: attachmentLabel,
|
||||||
ariaLabel: attachmentLabel,
|
ariaLabel: attachmentLabel,
|
||||||
@@ -309,7 +319,7 @@ export function useViewerRightRailButtons(
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "viewer-toggle-layers",
|
id: "viewer-toggle-layers",
|
||||||
icon: <LayersIcon sx={{ fontSize: "1.5rem" }} />,
|
icon: <LayersIcon sx={{ fontSize: "1.25rem" }} />,
|
||||||
tooltip: layersLabel,
|
tooltip: layersLabel,
|
||||||
ariaLabel: layersLabel,
|
ariaLabel: layersLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
@@ -323,7 +333,7 @@ export function useViewerRightRailButtons(
|
|||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
id: "viewer-toggle-comments",
|
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,
|
tooltip: commentsLabel,
|
||||||
ariaLabel: commentsLabel,
|
ariaLabel: commentsLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
@@ -333,17 +343,6 @@ export function useViewerRightRailButtons(
|
|||||||
toggleCommentsSidebar();
|
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",
|
id: "viewer-read-aloud",
|
||||||
tooltip: readAloudLabel,
|
tooltip: readAloudLabel,
|
||||||
@@ -373,7 +372,7 @@ export function useViewerRightRailButtons(
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isReadingAloud ? "filled" : "subtle"}
|
variant={isReadingAloud ? "filled" : "subtle"}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="workbench-bar-action-icon"
|
||||||
disabled={
|
disabled={
|
||||||
disabled ||
|
disabled ||
|
||||||
typeof window === "undefined" ||
|
typeof window === "undefined" ||
|
||||||
@@ -384,9 +383,9 @@ export function useViewerRightRailButtons(
|
|||||||
color={isReadingAloud ? "blue" : undefined}
|
color={isReadingAloud ? "blue" : undefined}
|
||||||
>
|
>
|
||||||
{isReadingAloud ? (
|
{isReadingAloud ? (
|
||||||
<StopIcon sx={{ fontSize: "1.5rem" }} />
|
<StopIcon sx={{ fontSize: "1.25rem" }} />
|
||||||
) : (
|
) : (
|
||||||
<VolumeUpIcon sx={{ fontSize: "1.5rem" }} />
|
<VolumeUpIcon sx={{ fontSize: "1.25rem" }} />
|
||||||
)}
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -421,9 +420,9 @@ export function useViewerRightRailButtons(
|
|||||||
/>
|
/>
|
||||||
{shouldShowLanguageSelector && (
|
{shouldShowLanguageSelector && (
|
||||||
<Select
|
<Select
|
||||||
label={t("rightRail.readAloudLanguage", "Language")}
|
label={t("workbenchBar.readAloudLanguage", "Language")}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"rightRail.selectLanguage",
|
"workbenchBar.selectLanguage",
|
||||||
"Select language",
|
"Select language",
|
||||||
)}
|
)}
|
||||||
value={speechLanguage}
|
value={speechLanguage}
|
||||||
@@ -461,7 +460,7 @@ export function useViewerRightRailButtons(
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isAnnotationsActive ? "filled" : "subtle"}
|
variant={isAnnotationsActive ? "filled" : "subtle"}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="workbench-bar-action-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (disabled || isAnnotationsActive) return;
|
if (disabled || isAnnotationsActive) return;
|
||||||
|
|
||||||
@@ -489,7 +488,7 @@ export function useViewerRightRailButtons(
|
|||||||
aria-pressed={isAnnotationsActive}
|
aria-pressed={isAnnotationsActive}
|
||||||
color={isAnnotationsActive ? "blue" : undefined}
|
color={isAnnotationsActive ? "blue" : undefined}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="edit" width="1.25rem" height="1.25rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -519,7 +518,7 @@ export function useViewerRightRailButtons(
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isFormFillActive ? "filled" : "subtle"}
|
variant={isFormFillActive ? "filled" : "subtle"}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="workbench-bar-action-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (isFormFillActive) {
|
if (isFormFillActive) {
|
||||||
@@ -532,7 +531,7 @@ export function useViewerRightRailButtons(
|
|||||||
aria-pressed={isFormFillActive}
|
aria-pressed={isFormFillActive}
|
||||||
color={isFormFillActive ? "blue" : undefined}
|
color={isFormFillActive ? "blue" : undefined}
|
||||||
>
|
>
|
||||||
<TextFieldsIcon sx={{ fontSize: "1.5rem" }} />
|
<TextFieldsIcon sx={{ fontSize: "1.25rem" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -560,7 +559,6 @@ export function useViewerRightRailButtons(
|
|||||||
bookmarkLabel,
|
bookmarkLabel,
|
||||||
attachmentLabel,
|
attachmentLabel,
|
||||||
layersLabel,
|
layersLabel,
|
||||||
printLabel,
|
|
||||||
tooltipPosition,
|
tooltipPosition,
|
||||||
annotationsLabel,
|
annotationsLabel,
|
||||||
isAnnotationsActive,
|
isAnnotationsActive,
|
||||||
@@ -586,5 +584,5 @@ export function useViewerRightRailButtons(
|
|||||||
handleSpeechLanguageChange,
|
handleSpeechLanguageChange,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useRightRailButtons(viewerButtons);
|
useWorkbenchBarButtons(viewerButtons);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user