mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +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:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
// core/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (stub)
|
||||
interface WorkbenchBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
export function WorkbenchBarFooterExtensions(_props: WorkbenchBarFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
// desktop/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
interface WorkbenchBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
export function WorkbenchBarFooterExtensions({ className }: WorkbenchBarFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
@@ -212,15 +212,15 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
// core/components/shared/WorkbenchBar.tsx (usage - works in ALL builds)
|
||||
import { WorkbenchBarFooterExtensions } from '@app/components/workbenchBar/WorkbenchBarFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
export function WorkbenchBar() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
<WorkbenchBarFooterExtensions className="workbench-bar-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ dist/
|
||||
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier)
|
||||
src-tauri/target/
|
||||
node_modules/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
public/vendor/
|
||||
public/pdfjs*/
|
||||
public/js/thirdParty/
|
||||
|
||||
@@ -52,8 +52,8 @@ exportAndContinue = "Export & Continue"
|
||||
false = "False"
|
||||
fileNotSavedToDisk = "Not saved to disk"
|
||||
fileSavedToDisk = "File saved to disk"
|
||||
fileSelected = "Selected: {{filename}}"
|
||||
filesSelected = "{{count}} files selected"
|
||||
fileSelected = "{{filename}}"
|
||||
filesSelected = "{{count}} files"
|
||||
font = "Font"
|
||||
fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text."
|
||||
fontTypeTooltip = "Font family for the page numbers. Choose based on your document style."
|
||||
@@ -82,7 +82,7 @@ multiPdfPrompt = "Select PDFs (2+)"
|
||||
never = "Never"
|
||||
no = "No"
|
||||
noFavourites = "No favourites added"
|
||||
noFileSelected = "No file selected. Please upload one."
|
||||
noFileSelected = "No file loaded. Please upload one."
|
||||
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
|
||||
noOperationToUndo = "No operation to undo"
|
||||
notAuthenticatedMessage = "User not authenticated."
|
||||
@@ -1460,14 +1460,14 @@ inactive = "Inactive"
|
||||
|
||||
[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."
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
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]
|
||||
actions = "Actions"
|
||||
@@ -2728,6 +2728,7 @@ used = "used"
|
||||
[compare]
|
||||
addFilesHint = "Add PDFs in the Files step to enable selection."
|
||||
clearSelected = "Clear selected"
|
||||
clearSlot = "Remove file"
|
||||
cta = "Compare"
|
||||
header = "Compare PDFs"
|
||||
loading = "Comparing..."
|
||||
@@ -2753,7 +2754,7 @@ label = "Original document"
|
||||
placeholder = "Select the original PDF"
|
||||
|
||||
[compare.clear]
|
||||
confirm = "Clear and return"
|
||||
confirm = "Clear Selected"
|
||||
confirmBody = "This will close the current comparison and take you back to Active Files."
|
||||
confirmTitle = "Clear selected PDFs?"
|
||||
|
||||
@@ -2779,6 +2780,7 @@ title = "These PDFs look highly different"
|
||||
|
||||
[compare.edited]
|
||||
label = "Edited PDF"
|
||||
selectBaseFirst = "Select original PDF first"
|
||||
placeholder = "Select the edited PDF"
|
||||
|
||||
[compare.error]
|
||||
@@ -2860,6 +2862,7 @@ pageLabel = "Page"
|
||||
|
||||
[compare.swap]
|
||||
confirm = "Swap and Re-run"
|
||||
label = "Swap"
|
||||
confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?"
|
||||
confirmTitle = "Re-run comparison?"
|
||||
|
||||
@@ -3109,7 +3112,7 @@ maintainAspectRatio = "Maintain Aspect Ratio"
|
||||
markdown = "Markdown"
|
||||
maxAttachmentSize = "Maximum attachment size (MB)"
|
||||
multiple = "Multiple"
|
||||
noFileSelected = "No file selected. Use the file panel to add files."
|
||||
noFileSelected = "No file loaded. Use the file panel to add files."
|
||||
odpExt = "OpenDocument Presentation (.odp)"
|
||||
odtExt = "OpenDocument Text (.odt)"
|
||||
officeDocs = "Office Documents (Word, Excel, PowerPoint)"
|
||||
@@ -3130,9 +3133,9 @@ pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will b
|
||||
pptExt = "PowerPoint (.pptx)"
|
||||
results = "Results"
|
||||
rtfExt = "Rich Text Format (.rtf)"
|
||||
selectedFiles = "Selected files"
|
||||
selectFilesPlaceholder = "Select files in the main view to get started"
|
||||
selectSourceFormatFirst = "Select a source format first"
|
||||
selectedFiles = "Active files"
|
||||
selectFilesPlaceholder = "Add files to the workbench to get started"
|
||||
selectSourceFormatFirst = "Choose a source format first"
|
||||
settings = "Settings"
|
||||
single = "Single"
|
||||
sourceFormatPlaceholder = "Source format"
|
||||
@@ -3287,7 +3290,7 @@ forRegularWork = "For regular PDF work:"
|
||||
[crop]
|
||||
autoCrop = "Auto-crop whitespace"
|
||||
header = "Crop PDF"
|
||||
noFileSelected = "Select a PDF file to begin cropping"
|
||||
noFileSelected = "Add a PDF file to begin cropping"
|
||||
reset = "Reset to full PDF"
|
||||
submit = "Apply Crop"
|
||||
title = "Crop"
|
||||
@@ -3492,6 +3495,8 @@ unlockAll = "Use for all ({{count}})"
|
||||
unlockAllPartialFail = "Wrong password for: {{names}}"
|
||||
unlockAllSuccess = "Unlocked {{count}} file(s)."
|
||||
unlockPrompt = "Unlock PDF to continue"
|
||||
viewerLocked = "This PDF is password-protected"
|
||||
viewerUnlock = "Unlock"
|
||||
|
||||
[encryptedPdfUnlock.password]
|
||||
label = "PDF password"
|
||||
@@ -3599,25 +3604,29 @@ or = "or"
|
||||
|
||||
[fileEditor]
|
||||
addFiles = "Add Files"
|
||||
pageCount = "{{count}} pages"
|
||||
pageCount_one = "{{count}} page"
|
||||
pageCount_other = "{{count}} pages"
|
||||
|
||||
[fileManager]
|
||||
active = "Active"
|
||||
addToUpload = "Add to Upload"
|
||||
changesNotUploaded = "Changes not uploaded"
|
||||
clearAll = "Clear All"
|
||||
clearSelection = "Clear Selection"
|
||||
clearSelection = "Clear"
|
||||
clickToUpload = "Click to upload files"
|
||||
closeAllFiles = "Close all files"
|
||||
closeFile = "Close File"
|
||||
cloudFile = "Cloud file"
|
||||
copyCreated = "Copy saved to this device."
|
||||
copyFailed = "Could not create a copy."
|
||||
delete = "Delete"
|
||||
deleteAll = "Delete All"
|
||||
deleteSelected = "Delete Selected"
|
||||
deselectAll = "Deselect All"
|
||||
deleteSelected = "Delete Files"
|
||||
deselectAll = "Uncheck All"
|
||||
details = "File Details"
|
||||
download = "Download"
|
||||
downloadSelected = "Download Selected"
|
||||
downloadSelected = "Download Files"
|
||||
dragDrop = "Drag & Drop files here"
|
||||
dropFilesHere = "Drop files here"
|
||||
failedToLoad = "Failed to load file to active set."
|
||||
@@ -3626,7 +3635,7 @@ fileFormat = "Format"
|
||||
fileHistory = "File History"
|
||||
fileName = "Name"
|
||||
fileSize = "Size"
|
||||
filesSelected = "files selected"
|
||||
filesSelected = "files"
|
||||
filesStored = "files stored"
|
||||
fileVersion = "Version"
|
||||
filterAll = "All"
|
||||
@@ -3652,7 +3661,7 @@ mobileUpload = "Mobile Upload"
|
||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||
myFiles = "My Files"
|
||||
noFiles = "No files available"
|
||||
noFileSelected = "No files selected"
|
||||
noFileSelected = "No files chosen"
|
||||
noFilesFound = "No files found matching your search"
|
||||
noRecentFiles = "No recent files found"
|
||||
openFile = "Open File"
|
||||
@@ -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."
|
||||
removeSharedServerOnlyPrompt = "This file is shared with you and stored only on the server. Remove it from your list?"
|
||||
restore = "Restore"
|
||||
saveSelected = "Save Selected"
|
||||
saveSelected = "Save Files"
|
||||
searchFiles = "Search files..."
|
||||
selectAll = "Select All"
|
||||
selectedCount = "{{count}} selected"
|
||||
selectedFiles = "Selected Files"
|
||||
selectAll = "Check All"
|
||||
selectedCount = "{{count}} checked"
|
||||
selectedFiles = "Files Added"
|
||||
share = "Share"
|
||||
shared = "Shared"
|
||||
sharedByYou = "Shared by you"
|
||||
sharedEditNoticeBody = "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy."
|
||||
sharedEditNoticeConfirm = "Got it"
|
||||
sharedEditNoticeTitle = "Read-only server copy"
|
||||
sharedWithYou = "Shared with you"
|
||||
shareSelected = "Share Selected"
|
||||
shareSelected = "Share Files"
|
||||
sharing = "Sharing"
|
||||
showAll = "Show All"
|
||||
showHistory = "Show History"
|
||||
@@ -3703,24 +3713,59 @@ supportMessage = "Powered by browser database storage for unlimited capacity"
|
||||
synced = "Synced"
|
||||
title = "Upload PDF Files"
|
||||
toolChain = "Tools Applied"
|
||||
totalSelected = "Total Selected"
|
||||
totalSelected = "Total Files"
|
||||
unsupported = "Unsupported"
|
||||
unzip = "Unzip"
|
||||
updateOnServer = "Update on Server"
|
||||
uploadError = "Failed to upload some files."
|
||||
uploadSelected = "Upload Selected"
|
||||
uploadSelected = "Upload Files"
|
||||
uploadToServer = "Upload to Server"
|
||||
|
||||
[files]
|
||||
addFiles = "Add files"
|
||||
created = "Created"
|
||||
selectFromWorkbench = "Select files from the workbench or "
|
||||
selectMultipleFromWorkbench = "Select at least {{count}} files from the workbench or "
|
||||
selectFromWorkbench = "Add files to the workbench or "
|
||||
selectMultipleFromWorkbench = "Add at least {{count}} files to the workbench or "
|
||||
size = "File Size"
|
||||
title = "Files"
|
||||
upload = "Upload"
|
||||
uploadFiles = "Upload Files"
|
||||
|
||||
[fileSelectorPicker]
|
||||
empty = "No files available"
|
||||
placeholder = "Select file"
|
||||
search = "Filter files…"
|
||||
tabListLabel = "Saved files, workbench, upload"
|
||||
upload = "Upload from computer"
|
||||
|
||||
[fileSelectorPicker.sort]
|
||||
dateAsc = "Oldest first"
|
||||
dateDesc = "Newest first"
|
||||
dateLabel = "Latest"
|
||||
nameAsc = "A to Z"
|
||||
nameDesc = "Z to A"
|
||||
nameLabel = "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]
|
||||
credit = "This service uses LibreOffice and Unoconv for file conversion."
|
||||
header = "Convert any file to PDF"
|
||||
@@ -4592,7 +4637,7 @@ goToFileEditor = "Go to file editor"
|
||||
submit = "Merge"
|
||||
tags = "merge,Page operations,Back end,server side"
|
||||
title = "Merge"
|
||||
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them."
|
||||
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to add them."
|
||||
|
||||
[merge.error]
|
||||
failed = "An error occurred while merging the PDFs."
|
||||
@@ -4615,7 +4660,7 @@ title = "Remove Digital Signature"
|
||||
ascending = "Ascending"
|
||||
dateModified = "Date Modified"
|
||||
descending = "Descending"
|
||||
description = "Files will be merged in the order they're selected. Drag to reorder or sort below."
|
||||
description = "Files will be merged in the order shown. Drag to reorder or sort below."
|
||||
filename = "File Name"
|
||||
label = "Sort By"
|
||||
sort = "Sort"
|
||||
@@ -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."
|
||||
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."
|
||||
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]
|
||||
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."
|
||||
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."
|
||||
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>."
|
||||
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"
|
||||
|
||||
[pageEdit]
|
||||
deselectAll = "Select None"
|
||||
selectAll = "Select All"
|
||||
deselectAll = "None"
|
||||
selectAll = "Check All"
|
||||
|
||||
[pageEditor]
|
||||
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."
|
||||
title = "Invert All Colours"
|
||||
|
||||
[rightRail]
|
||||
[workbenchBar]
|
||||
annotations = "Annotations"
|
||||
applyRedactionsFirst = "Apply redactions first"
|
||||
closeAll = "Close All Files"
|
||||
closePdf = "Close PDF"
|
||||
closeSelected = "Close Selected Files"
|
||||
deleteSelected = "Delete Selected Pages"
|
||||
@@ -6557,6 +6602,7 @@ search = "Search PDF"
|
||||
selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
@@ -6564,6 +6610,9 @@ toggleComments = "Comments"
|
||||
toggleLayers = "Toggle Layers"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleTheme = "Toggle Theme"
|
||||
activeFiles = "Active Files"
|
||||
multiTool = "Multi-Tool"
|
||||
viewer = "Viewer"
|
||||
|
||||
[rotate]
|
||||
rotateLeft = "Rotate Anticlockwise"
|
||||
@@ -6797,7 +6846,7 @@ defaultPdfEditorChecking = "Checking..."
|
||||
defaultPdfEditorInactive = "Another application is set as default"
|
||||
defaultPdfEditorSet = "Already Default"
|
||||
defaultStartupView = "Default view on launch"
|
||||
defaultStartupViewDescription = "Choose which tab is active in the left column when the app starts"
|
||||
defaultStartupViewDescription = "Choose which view is active when the app starts"
|
||||
defaultToolPickerMode = "Default tool picker mode"
|
||||
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
|
||||
defaultViewerZoom = "Default reader zoom"
|
||||
@@ -6807,8 +6856,14 @@ hideUnavailableConversions = "Hide unavailable conversions"
|
||||
hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||
hideUnavailableTools = "Hide unavailable tools"
|
||||
hideUnavailableToolsDescription = "Remove tools that have been disabled by your server instead of showing them greyed out."
|
||||
language = "Language"
|
||||
languageDescription = "Choose the display language"
|
||||
logout = "Log out"
|
||||
setAsDefault = "Set as Default"
|
||||
theme = "Theme"
|
||||
themeDark = "Dark"
|
||||
themeDescription = "Switch between light and dark mode"
|
||||
themeLight = "Light"
|
||||
title = "General"
|
||||
user = "User"
|
||||
|
||||
@@ -6983,6 +7038,20 @@ title = "Team"
|
||||
enableLoginFirst = "Enable login mode first"
|
||||
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]
|
||||
people = "People"
|
||||
teams = "Teams"
|
||||
@@ -7580,8 +7649,8 @@ accessLimitedCommenter = "Comment access is coming soon. Ask the owner for edito
|
||||
accessLimitedTitle = "Limited access"
|
||||
accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download."
|
||||
addUser = "Add"
|
||||
bulkDescription = "Create one link to share all selected files with signed-in users."
|
||||
bulkTitle = "Share selected files"
|
||||
bulkDescription = "Create one link to share all checked files with signed-in users."
|
||||
bulkTitle = "Share checked files"
|
||||
commenterHint = "Commenting is coming soon."
|
||||
copied = "Link copied to clipboard"
|
||||
copy = "Copy"
|
||||
@@ -7600,7 +7669,7 @@ errorTitle = "Share failed"
|
||||
expiredBody = "This share link is invalid or has expired."
|
||||
expiredTitle = "Link expired"
|
||||
failure = "Unable to generate a share link. Please try again."
|
||||
fileCount = "{{count}} files selected"
|
||||
fileCount = "{{count}} files"
|
||||
fileLabel = "File"
|
||||
generate = "Generate Link"
|
||||
generated = "Share link generated"
|
||||
@@ -7656,12 +7725,12 @@ viewed = "Viewed"
|
||||
viewsCount = "Views: {{count}}"
|
||||
|
||||
[storageUpload]
|
||||
bulkDescription = "This uploads the selected files to your server storage."
|
||||
bulkTitle = "Upload selected files"
|
||||
bulkDescription = "This uploads the checked files to your server storage."
|
||||
bulkTitle = "Upload checked files"
|
||||
description = "This uploads the current file to server storage for your own access."
|
||||
errorTitle = "Upload failed"
|
||||
failure = "Upload failed. Please check your login and storage settings."
|
||||
fileCount = "{{count}} files selected"
|
||||
fileCount = "{{count}} files"
|
||||
fileLabel = "File"
|
||||
hint = "Public links and access modes are controlled by your server settings."
|
||||
more = " +{{count}} more"
|
||||
@@ -7821,19 +7890,26 @@ settings = "Settings"
|
||||
[tool]
|
||||
endpointUnavailable = "This tool is unavailable on your server."
|
||||
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
|
||||
filesLoading = "Files are still loading, please wait."
|
||||
filesLoadingProgress = "{{loaded}} / {{total}} files loading..."
|
||||
invalidParams = "Fill in the required settings."
|
||||
noFiles = "Add a file to get started."
|
||||
scopeFiles = "files"
|
||||
scopeThisFile = "this file"
|
||||
selectFilesHint = "Select files in Active Files to run this tool"
|
||||
singleFileScope = "Only applying to: {{fileName}}"
|
||||
viewerMode = "Switch to the file editor to select multiple files."
|
||||
viewerMode = "Switch to the file editor to add multiple files."
|
||||
|
||||
[toolPanel]
|
||||
allTools = "All tools"
|
||||
alpha = "Alpha"
|
||||
backToTools = "Back to tools"
|
||||
collapse = "Collapse panel"
|
||||
comingSoon = "Coming soon:"
|
||||
expand = "Expand panel"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
search = "Search tools"
|
||||
|
||||
[toolPanel.fullscreen]
|
||||
comingSoon = "Coming soon:"
|
||||
@@ -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."
|
||||
title = "Upload Signature Image"
|
||||
|
||||
|
||||
[workspace]
|
||||
title = "Workspace"
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.h-screen,
|
||||
.right-rail {
|
||||
.h-screen {
|
||||
height: 100% !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
AppConfigRetryOptions,
|
||||
useAppConfig,
|
||||
} from "@app/contexts/AppConfigContext";
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { WorkbenchBarProvider } from "@app/contexts/WorkbenchBarContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
|
||||
@@ -139,13 +139,13 @@ export function AppProviders({
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<WorkbenchBarProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</WorkbenchBarProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@app/services/googleDrivePickerService";
|
||||
import { loadScript } from "@app/utils/scriptLoader";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@@ -28,6 +30,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
closeFilesModal,
|
||||
onFileUpload,
|
||||
onRecentFileSelect,
|
||||
maxSelectable,
|
||||
} = useFilesModalContext();
|
||||
const { config } = useAppConfig();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
@@ -35,6 +38,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
// Get active file IDs from FileContext to show which files are already loaded
|
||||
const { fileIds: activeFileIds } = useAllFiles();
|
||||
@@ -83,11 +87,24 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(
|
||||
async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
await handleRemoveFile(
|
||||
index,
|
||||
recentFiles,
|
||||
setRecentFiles,
|
||||
(fileId) => {
|
||||
fileActions.removeFiles([fileId], false);
|
||||
},
|
||||
refreshRecentFiles,
|
||||
);
|
||||
},
|
||||
[handleRemoveFile, recentFiles],
|
||||
[handleRemoveFile, recentFiles, fileActions, refreshRecentFiles],
|
||||
);
|
||||
|
||||
const handleBulkRemove = useCallback((fileIds: FileId[]) => {
|
||||
const idSet = new Set(fileIds as string[]);
|
||||
setRecentFiles((prev) => prev.filter((f) => !idSet.has(f.id as string)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||
checkMobile();
|
||||
@@ -211,7 +228,9 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
isOpen={isFilesModalOpen}
|
||||
maxSelectable={maxSelectable}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
onBulkRemove={handleBulkRemove}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
@@ -69,7 +68,7 @@ const AddFileCard = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
className={`${styles.addFileCard} select-none flex flex-col transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t("fileEditor.addFiles", "Add files")}
|
||||
@@ -81,17 +80,6 @@ const AddFileCard = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t("fileEditor.addFiles", "Add Files")}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
{/* Stirling PDF Branding */}
|
||||
|
||||
@@ -328,42 +328,33 @@
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
width: 260px;
|
||||
max-width: 260px;
|
||||
height: calc(310px - 0.5rem);
|
||||
margin: 0.5rem auto 0;
|
||||
background: var(--file-card-bg);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 0.0625rem;
|
||||
border: 1.5px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.2s ease,
|
||||
border-color 0.18s ease;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.addFileCard:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-blue-500);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--accent-interactive);
|
||||
}
|
||||
|
||||
.addFileCard:focus {
|
||||
outline: 2px solid var(--color-blue-500);
|
||||
.addFileCard:focus-visible {
|
||||
outline: 2px solid var(--accent-interactive);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.addFileHeader {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileHeader {
|
||||
background: var(--color-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.addFileContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { Center, Box, LoadingOverlay } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import {
|
||||
useFileSelection,
|
||||
useFileState,
|
||||
useFileManagement,
|
||||
useFileActions,
|
||||
useFileContext,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
@@ -19,7 +18,6 @@ import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -46,17 +44,13 @@ const FileEditor = ({
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: fileContextActions } = useFileContext();
|
||||
const { clearAllFileErrors } = fileContextActions;
|
||||
const { selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(
|
||||
() => selectors.getStirlingFileStubs(),
|
||||
[state.files.byId, state.files.ids],
|
||||
);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const totalItems = state.files.ids.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
@@ -64,9 +58,6 @@ const FileEditor = ({
|
||||
// Get viewer context for setting active file index and ID
|
||||
const { setActiveFileIndex, setActiveFileId } = useViewer();
|
||||
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
@@ -93,7 +84,6 @@ const FileEditor = ({
|
||||
expandable: true,
|
||||
});
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Current tool (for enforcing maxFiles limits)
|
||||
const { selectedTool } = useToolWorkflow();
|
||||
@@ -104,65 +94,7 @@ const FileEditor = ({
|
||||
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
}, [selectedTool?.maxFiles, toolMode]);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds)
|
||||
? selectedFileIds
|
||||
: [];
|
||||
|
||||
// Create refs for frequently changing values to stabilize callbacks
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
contextSelectedIdsRef.current = contextSelectedIds;
|
||||
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
const handleSelectAllFiles = useCallback(() => {
|
||||
// Respect maxAllowed: if limited, select the last N files
|
||||
const allIds = state.files.ids;
|
||||
const idsToSelect = Number.isFinite(maxAllowed)
|
||||
? allIds.slice(-maxAllowed)
|
||||
: allIds;
|
||||
setSelectedFiles(idsToSelect);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Failed to clear file errors on select all:", error);
|
||||
}
|
||||
}
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
|
||||
|
||||
const handleDeselectAllFiles = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Failed to clear file errors on deselect:", error);
|
||||
}
|
||||
}
|
||||
}, [setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleCloseSelectedFiles = useCallback(() => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
void removeFiles(selectedFileIds, false);
|
||||
setSelectedFiles([]);
|
||||
}, [selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll: handleSelectAllFiles,
|
||||
onDeselectAll: handleDeselectAllFiles,
|
||||
onCloseSelected: handleCloseSelectedFiles,
|
||||
});
|
||||
|
||||
// Process uploaded files using context
|
||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||
@@ -199,56 +131,6 @@ const FileEditor = ({
|
||||
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
|
||||
);
|
||||
|
||||
const toggleFile = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: FileId[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
|
||||
} else {
|
||||
// Add file to selection
|
||||
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
const maxAllowed =
|
||||
!toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
// Only one file allowed -> replace selection with the new file
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// If at capacity, drop the oldest selected and append the new one
|
||||
if (
|
||||
Number.isFinite(maxAllowed) &&
|
||||
currentSelectedIds.length >= maxAllowed
|
||||
) {
|
||||
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
||||
} else {
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
},
|
||||
[
|
||||
setSelectedFiles,
|
||||
toolMode,
|
||||
_setStatus,
|
||||
activeStirlingFileStubs,
|
||||
selectedTool?.maxFiles,
|
||||
],
|
||||
);
|
||||
|
||||
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
|
||||
@@ -495,16 +377,7 @@ const FileEditor = ({
|
||||
<Box p="md">
|
||||
{activeStirlingFileStubs.length === 0 ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
📁
|
||||
</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Upload PDF files, ZIP archives, or load from storage to get
|
||||
started
|
||||
</Text>
|
||||
</Stack>
|
||||
<AddFileCard onFileSelect={handleFileUpload} />
|
||||
</Center>
|
||||
) : (
|
||||
<div
|
||||
@@ -531,12 +404,8 @@ const FileEditor = ({
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
|
||||
@@ -0,0 +1,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 {
|
||||
Text,
|
||||
ActionIcon,
|
||||
CheckboxIndicator,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Loader,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { alert } from "@app/components/toast";
|
||||
@@ -21,7 +19,6 @@ import UnarchiveIcon from "@mui/icons-material/Unarchive";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
@@ -31,11 +28,10 @@ import {
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
|
||||
import styles from "@app/components/fileEditor/FileEditor.module.css";
|
||||
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/file/fileHooks";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { formatFileSize } from "@app/utils/fileUtils";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
@@ -45,18 +41,17 @@ import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
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 {
|
||||
file: StirlingFileStub;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (
|
||||
sourceFileId: FileId,
|
||||
targetFileId: FileId,
|
||||
@@ -70,12 +65,8 @@ interface FileEditorThumbnailProps {
|
||||
|
||||
const FileEditorThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onCloseFile,
|
||||
onViewFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
@@ -95,41 +86,41 @@ const FileEditorThumbnail = ({
|
||||
openEncryptedUnlockPrompt,
|
||||
} = useFileContext();
|
||||
const { state, selectors } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
|
||||
const sharedEditNoticeShownRef = useRef(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find((f) => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const actualFile = useMemo(
|
||||
() => activeFiles.find((f) => f.fileId === file.id),
|
||||
[activeFiles, file.id],
|
||||
);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
|
||||
const {
|
||||
isEncrypted,
|
||||
thumbnail: displayThumbnail,
|
||||
isGenerating: isThumbGenerating,
|
||||
} = useFileThumbnail(file);
|
||||
|
||||
// Aspect ratio from page dimensions, falling back to letter size
|
||||
const firstPage = file.processedFile?.pages?.[0];
|
||||
const firstPageRotation = firstPage?.rotation ?? 0;
|
||||
const isLandscape = firstPageRotation === 90 || firstPageRotation === 270;
|
||||
const thumbAspect = (() => {
|
||||
const w = firstPage?.width;
|
||||
const h = firstPage?.height;
|
||||
if (w && h && w > 0 && h > 0) {
|
||||
// width/height are effective (post-rotation) dims from PDFium, so use
|
||||
// them directly — no swapping needed.
|
||||
return `${w} / ${h}`;
|
||||
}
|
||||
return isLandscape ? "11 / 8.5" : "8.5 / 11";
|
||||
})();
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
|
||||
// ---- Meta formatting ----
|
||||
const prettySize = useMemo(() => {
|
||||
return formatFileSize(file.size);
|
||||
}, [file.size]);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const extUpper = useMemo(() => {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
||||
@@ -143,6 +134,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const isCBZ = extLower === "cbz";
|
||||
const isCBR = extLower === "cbr";
|
||||
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled =
|
||||
uploadEnabled && config?.storageSharingEnabled === true;
|
||||
@@ -178,7 +170,13 @@ const FileEditorThumbnail = ({
|
||||
}).format(d);
|
||||
}, [file.lastModified]);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
|
||||
const sharedEditNoticeShownRef = useRef(false);
|
||||
|
||||
const fileElementRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
@@ -191,14 +189,10 @@ const FileEditorThumbnail = ({
|
||||
type: "file",
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: [file.id], // Always drag only this file, ignore selection state
|
||||
selectedFiles: [file.id],
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
@@ -211,10 +205,7 @@ const FileEditorThumbnail = ({
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === "file" && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDragEnter: () => setIsDragOver(true),
|
||||
onDragLeave: () => setIsDragOver(false),
|
||||
onDrop: ({ source }) => {
|
||||
setIsDragOver(false);
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === "file" && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
@@ -229,13 +220,14 @@ const FileEditorThumbnail = ({
|
||||
dropCleanup();
|
||||
};
|
||||
},
|
||||
[file.id, file.name, selectedFiles, onReorderFiles],
|
||||
[file.id, file.name, onReorderFiles],
|
||||
);
|
||||
|
||||
// Handle close with confirmation
|
||||
const handleCloseWithConfirmation = useCallback(() => {
|
||||
setShowCloseModal(true);
|
||||
}, []);
|
||||
const handleCloseWithConfirmation = useCallback(
|
||||
() => setShowCloseModal(true),
|
||||
[],
|
||||
);
|
||||
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
|
||||
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
onCloseFile(file.id);
|
||||
@@ -278,7 +270,6 @@ const FileEditorThumbnail = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Then close
|
||||
onCloseFile(file.id);
|
||||
alert({
|
||||
alertType: "success",
|
||||
@@ -296,11 +287,6 @@ const FileEditorThumbnail = ({
|
||||
fileActions,
|
||||
]);
|
||||
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseModal(false);
|
||||
}, []);
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -312,6 +298,36 @@ const FileEditorThumbnail = ({
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pin",
|
||||
icon: <PushPinIcon style={{ fontSize: 20 }} />,
|
||||
label: isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)"),
|
||||
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",
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
@@ -321,36 +337,32 @@ const FileEditorThumbnail = ({
|
||||
onDownloadFile(file.id);
|
||||
},
|
||||
},
|
||||
...(canUpload || canShare
|
||||
...(canUpload
|
||||
? [
|
||||
...(canUpload
|
||||
? [
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t("fileManager.updateOnServer", "Update on Server")
|
||||
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canShare
|
||||
? [
|
||||
{
|
||||
id: "share",
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.share", "Share"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t("fileManager.updateOnServer", "Update on Server")
|
||||
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canShare
|
||||
? [
|
||||
{
|
||||
id: "share",
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.share", "Share"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
@@ -389,7 +401,10 @@ const FileEditorThumbnail = ({
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
isPinned,
|
||||
actualFile,
|
||||
terminology,
|
||||
DownloadOutlinedIcon,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
@@ -397,13 +412,13 @@ const FileEditorThumbnail = ({
|
||||
canUpload,
|
||||
canShare,
|
||||
isUploaded,
|
||||
pinFile,
|
||||
unpinFile,
|
||||
],
|
||||
);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try {
|
||||
fileActions.clearFileError(file.id);
|
||||
@@ -415,7 +430,6 @@ const FileEditorThumbnail = ({
|
||||
sharedEditNoticeShownRef.current = true;
|
||||
setShowSharedEditNotice(true);
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
@@ -423,12 +437,9 @@ const FileEditorThumbnail = ({
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
if (!isSupported) return styles.headerUnsupported;
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
|
||||
.filter(Boolean)
|
||||
.join(" - ");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -436,268 +447,130 @@ const FileEditorThumbnail = ({
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-tour="file-card-checkbox"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={
|
||||
{
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
outline: isDragOver
|
||||
? "3px dashed var(--mantine-color-blue-5, #3b82f6)"
|
||||
: undefined,
|
||||
outlineOffset: isDragOver ? "2px" : undefined,
|
||||
transform: isDragOver ? "scale(1.02)" : undefined,
|
||||
transition:
|
||||
"outline 120ms ease, transform 120ms ease, opacity 120ms ease",
|
||||
// Tag each card with a stable, unique view-transition-name so the
|
||||
// browser can animate the reorder (see FileEditor.handleReorderFiles,
|
||||
// which dispatches reorderFiles inside document.startViewTransition).
|
||||
viewTransitionName: `file-card-${file.id}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`${styles.card} select-none`}
|
||||
style={{ opacity: isDragging ? 0.9 : 1 }}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setShowHoverMenu(true)}
|
||||
onMouseLeave={() => setShowHoverMenu(false)}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t("error._value", "Error")}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
color="var(--checkbox-checked-bg)"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>{t("unsupported", "Unsupported")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div
|
||||
className={styles.headerIndex}
|
||||
aria-label={`Position ${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{isEncrypted && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
<div className={styles.thumbInner}>
|
||||
{/* Thumbnail area */}
|
||||
<div className={styles.thumbWrap}>
|
||||
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
|
||||
<div className={styles.thumbUnit}>
|
||||
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
|
||||
<div className={styles.toolChainBar}>
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth="100%"
|
||||
color="var(--mantine-color-gray-7)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.thumbContainer}
|
||||
data-supported={isSupported}
|
||||
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
>
|
||||
<LockOpenIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip
|
||||
label={
|
||||
isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={
|
||||
isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
data-tour="file-card-pin"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({
|
||||
alertType: "neutral",
|
||||
title: `Unpinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: `Pinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
}
|
||||
{/* Error overlay */}
|
||||
{hasError && (
|
||||
<div className={styles.errorOverlay}>
|
||||
<span className={styles.errorPill}>
|
||||
{t("error._value", "Error")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail image or loading state */}
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
thumbnail={displayThumbnail || undefined}
|
||||
isEncrypted={isEncrypted}
|
||||
isLoading={
|
||||
!isEncrypted &&
|
||||
!displayThumbnail &&
|
||||
(isThumbGenerating ||
|
||||
file.type?.startsWith("application/pdf") ||
|
||||
file.type?.startsWith("image/"))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? (
|
||||
<PushPinIcon fontSize="small" />
|
||||
) : (
|
||||
<PushPinOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
textAlign: "center",
|
||||
background: "var(--file-card-bg)",
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={700}
|
||||
className={styles.title}
|
||||
title={file.name}
|
||||
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",
|
||||
iconSize="6rem"
|
||||
imgClassName={styles.thumbImage}
|
||||
onImageError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : file.type?.startsWith("application/pdf") ? (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* Badges — top-left: version, pin, ownership, encrypted */}
|
||||
<div className={styles.thumbBadges}>
|
||||
<span className={styles.versionBadgeThumb}>
|
||||
v{file.versionNumber}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={styles.pinnedBadge}>
|
||||
<PushPinIcon style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
)}
|
||||
{isSharedFile && !isOwnedOrLocal && (
|
||||
<span className={styles.ownershipBadge}>
|
||||
{t("fileManager.sharedWithYou", "Shared")}
|
||||
</span>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<LockOpenIcon style={{ fontSize: 12 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FileEditorStatusDot file={file} />
|
||||
</div>
|
||||
</div>
|
||||
{/* end thumbUnit */}
|
||||
|
||||
{/* Drag handle */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
|
||||
{/* Tool chain display at bottom */}
|
||||
{file.toolHistory && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "4px",
|
||||
left: "4px",
|
||||
right: "4px",
|
||||
padding: "4px 6px",
|
||||
textAlign: "center",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth={"100%"}
|
||||
color="var(--mantine-color-gray-7)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
|
||||
<HoverActionMenu
|
||||
show={isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
visibility="cssHover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu
|
||||
show={showHoverMenu || isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
/>
|
||||
{/* File name + meta */}
|
||||
<div className={styles.fileText}>
|
||||
<p className={styles.fileName}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</p>
|
||||
<p className={styles.fileMeta}>{metaLine}</p>
|
||||
</div>
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
@@ -714,7 +587,7 @@ const FileEditorThumbnail = ({
|
||||
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
@@ -741,7 +614,7 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
@@ -759,6 +632,8 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Shared edit notice modal */}
|
||||
<Modal
|
||||
opened={showSharedEditNotice}
|
||||
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;
|
||||
onNext: () => void;
|
||||
onOpenFiles: () => void;
|
||||
canCloseAll?: boolean;
|
||||
}
|
||||
|
||||
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
@@ -30,6 +31,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
onPrevious,
|
||||
onNext,
|
||||
onOpenFiles,
|
||||
canCloseAll = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
@@ -94,7 +96,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<PrivateContent>
|
||||
{currentFile ? currentFile.name : "No file selected"}
|
||||
{currentFile ? currentFile.name : "No file loaded"}
|
||||
</PrivateContent>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -149,18 +151,21 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection}
|
||||
disabled={!hasSelection && !canCloseAll}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
backgroundColor:
|
||||
hasSelection || canCloseAll
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
{canCloseAll
|
||||
? t("fileManager.closeAllFiles", "Close all files")
|
||||
: selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ interface FileDetailsProps {
|
||||
}
|
||||
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||
const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } =
|
||||
useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
@@ -21,6 +22,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
const currentFile =
|
||||
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
const hasActiveFiles = activeFileIds.length > 0;
|
||||
// Enable "Close all files" when nothing is checked but files are open in workbench
|
||||
const canCloseAll = !hasSelection && hasActiveFiles;
|
||||
|
||||
// Use IndexedDB hook for the current file
|
||||
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
||||
@@ -71,6 +75,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onOpenFiles={onOpenFiles}
|
||||
canCloseAll={canCloseAll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -104,18 +109,21 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
<Button
|
||||
size="md"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection}
|
||||
disabled={!hasSelection && !canCloseAll}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
backgroundColor:
|
||||
hasSelection || canCloseAll
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
{canCloseAll
|
||||
? t("fileManager.closeAllFiles", "Close all files")
|
||||
: selectedFiles.length > 1
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ interface FileHistoryGroupProps {
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
@@ -21,7 +20,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
onDownloadSingle,
|
||||
onFileDoubleClick,
|
||||
onHistoryFileRemove,
|
||||
isFileSupported,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -50,7 +48,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
isSelected={false} // History files are not selectable
|
||||
isSupported={isFileSupported(historyFile.name)}
|
||||
onSelect={() => {}} // No selection for history files
|
||||
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
|
||||
onDownload={() => onDownloadSingle(historyFile)}
|
||||
|
||||
@@ -28,7 +28,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onHistoryFileRemove,
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
activeFileIds,
|
||||
} = useFileManagerContext();
|
||||
@@ -65,7 +64,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
<FileListItem
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
@@ -82,7 +80,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onDownloadSingle={onDownloadSingle}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
onHistoryFileRemove={onHistoryFileRemove}
|
||||
isFileSupported={isFileSupported}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,6 @@ import { alert } from "@app/components/toast";
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
isSelected: boolean;
|
||||
isSupported: boolean;
|
||||
onSelect: (shiftKey?: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onDownload?: () => void;
|
||||
@@ -53,7 +52,6 @@ interface FileListItemProps {
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
file,
|
||||
isSelected,
|
||||
isSupported,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onDownload,
|
||||
@@ -187,15 +185,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: isHistoryFile || isActive ? "default" : "pointer",
|
||||
backgroundColor: isActive
|
||||
? "var(--file-active-bg)"
|
||||
: isSelected
|
||||
cursor: isHistoryFile ? "default" : "pointer",
|
||||
backgroundColor:
|
||||
isSelected || shouldShowHovered
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: shouldShowHovered
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: "var(--bg-file-list)",
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
: "var(--bg-file-list)",
|
||||
opacity: 1,
|
||||
transition: "background-color 0.15s ease",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
@@ -206,9 +201,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
? "3px solid var(--mantine-color-blue-4)"
|
||||
: "none", // Visual indicator for history
|
||||
}}
|
||||
onClick={
|
||||
isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)
|
||||
}
|
||||
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -218,16 +211,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Box>
|
||||
{/* Checkbox for regular files only */}
|
||||
<Checkbox
|
||||
checked={isActive || isSelected}
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
disabled={isActive}
|
||||
color={isActive ? "green" : undefined}
|
||||
styles={{
|
||||
input: {
|
||||
cursor: isActive ? "not-allowed" : "pointer",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -10,28 +10,12 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Drive icon component - displays the branded SVG icon
|
||||
* Shows grayscale when disabled
|
||||
*/
|
||||
const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
|
||||
<img
|
||||
src="/images/google-drive.svg"
|
||||
alt="Google Drive"
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
filter: disabled ? "grayscale(100%)" : "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false,
|
||||
}) => {
|
||||
@@ -144,7 +128,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
|
||||
leftSection={<GoogleDriveIcon colored={isGoogleDriveEnabled} />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
/* WorkbenchBar slide-in/out animation using CSS grid trick */
|
||||
.workbenchBarWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 280ms ease;
|
||||
}
|
||||
|
||||
.workbenchBarWrapper[data-hidden="true"] {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.workbenchBarWrapper[data-no-transition="true"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Direct child must have min-height: 0 so the row can collapse below content size */
|
||||
.workbenchBarInner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbenchScrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Suspense, lazy, useCallback } from "react";
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { Box, Loader, Center } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
useNavigationGuard,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { isBaseWorkbench } from "@app/types/workbench";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { FileId } from "@app/types/file";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import TopControls from "@app/components/shared/TopControls";
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
import LandingPage from "@app/components/shared/LandingPage";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||
@@ -37,7 +35,6 @@ export default function Workbench() {
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
@@ -61,34 +58,15 @@ export default function Workbench() {
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
const hasFiles = activeFiles.length > 0;
|
||||
|
||||
// Get active file index from ViewerContext
|
||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
|
||||
// Get navigation guard for unsaved changes check when switching files
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
|
||||
// Wrap file selection to check for unsaved changes before switching
|
||||
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
|
||||
const handleFileSelect = useCallback(
|
||||
(index: number) => {
|
||||
// Don't do anything if selecting the same file
|
||||
if (index === activeFileIndex) return;
|
||||
|
||||
// requestNavigation handles the unsaved changes check internally
|
||||
requestNavigation(() => {
|
||||
setActiveFileIndex(index);
|
||||
});
|
||||
},
|
||||
[activeFileIndex, requestNavigation, setActiveFileIndex],
|
||||
);
|
||||
|
||||
const handleFileRemove = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
|
||||
},
|
||||
[fileActions],
|
||||
);
|
||||
// Enable bar transitions after first paint so the initial hidden state shows
|
||||
// without animating (landing page on load shouldn't animate the bar up).
|
||||
const [barTransitionEnabled, setBarTransitionEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => setBarTransitionEnabled(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
@@ -130,7 +108,9 @@ export default function Workbench() {
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
supportedExtensions={
|
||||
selectedTool?.supportedFormats || VIEWER_SUPPORTED_EXTENSIONS
|
||||
}
|
||||
{...(!selectedToolId && {
|
||||
onOpenPageEditor: () => {
|
||||
setCurrentView("pageEditor");
|
||||
@@ -150,8 +130,6 @@ export default function Workbench() {
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -195,7 +173,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
default:
|
||||
return <LandingPage />;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,27 +187,23 @@ export default function Workbench() {
|
||||
: { backgroundColor: "var(--bg-background)" }
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 &&
|
||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map((f) => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return {
|
||||
fileId: f.fileId,
|
||||
name: f.name,
|
||||
versionNumber: stub?.versionNumber,
|
||||
};
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleFileRemove}
|
||||
/>
|
||||
)}
|
||||
{/* Workbench Bar - animates in/out based on file presence */}
|
||||
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
@@ -232,12 +232,15 @@ export default function Onboarding() {
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
pinFile: tourOrch.pinFile,
|
||||
revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
openFilesModal,
|
||||
openSettingsHelpSection: () =>
|
||||
adminTourOrch.navigateToSection("help"),
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||
[t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal],
|
||||
);
|
||||
|
||||
const whatsNewStepsConfig = useMemo(
|
||||
@@ -253,7 +256,6 @@ export default function Onboarding() {
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
addGlowToElements,
|
||||
removeAllGlows,
|
||||
} from "@app/components/onboarding/tourGlow";
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
@@ -57,7 +61,7 @@ export function createAdminStepsConfig({
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t(
|
||||
"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",
|
||||
padding: 10,
|
||||
@@ -204,15 +208,18 @@ export function createAdminStepsConfig({
|
||||
},
|
||||
},
|
||||
[AdminTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
selector: '[data-tour="admin-help-nav"]',
|
||||
content: t(
|
||||
"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",
|
||||
padding: 10,
|
||||
action: () => {
|
||||
action: async () => {
|
||||
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 { TFunction } from "i18next";
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum TourStep {
|
||||
ALL_TOOLS,
|
||||
@@ -26,9 +30,11 @@ interface UserStepActions {
|
||||
loadSampleFile: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
pinFile: () => void;
|
||||
revealFileCardHoverMenu: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
openFilesModal: () => void;
|
||||
openSettingsHelpSection: () => void;
|
||||
}
|
||||
|
||||
interface CreateUserStepsConfigArgs {
|
||||
@@ -48,9 +54,11 @@ export function createUserStepsConfig({
|
||||
loadSampleFile,
|
||||
switchToActiveFiles,
|
||||
pinFile,
|
||||
revealFileCardHoverMenu,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
openSettingsHelpSection,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
@@ -95,7 +103,7 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
actionAfter: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
@@ -105,6 +113,10 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 0,
|
||||
action: async () => {
|
||||
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||
await waitForHighlightable('[data-tour="file-sources"]', 5000);
|
||||
},
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
@@ -184,16 +196,22 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "left",
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
action: () => revealFileCardHoverMenu(),
|
||||
actionAfter: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
selector: '[data-tour="admin-help-nav"]',
|
||||
content: t(
|
||||
"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",
|
||||
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 { TFunction } from "i18next";
|
||||
|
||||
async function waitForElement(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
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();
|
||||
});
|
||||
}
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum WhatsNewTourStep {
|
||||
QUICK_ACCESS,
|
||||
LEFT_PANEL,
|
||||
FILE_UPLOAD,
|
||||
RIGHT_RAIL,
|
||||
TOP_BAR,
|
||||
PAGE_EDITOR_VIEW,
|
||||
ACTIVE_FILES_VIEW,
|
||||
@@ -68,7 +24,6 @@ interface WhatsNewStepActions {
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
}
|
||||
|
||||
interface CreateWhatsNewStepsConfigArgs {
|
||||
@@ -89,7 +44,6 @@ export function createWhatsNewStepsConfig({
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
@@ -124,34 +78,15 @@ export function createWhatsNewStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||
await Promise.resolve(loadSampleFile());
|
||||
closeFilesModal();
|
||||
switchToViewer();
|
||||
// wait for file render and top controls to mount
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[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();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.TOP_BAR]: {
|
||||
@@ -165,8 +100,8 @@ export function createWhatsNewStepsConfig({
|
||||
// Ensure the switcher has mounted before this step renders
|
||||
action: async () => {
|
||||
switchToViewer();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||
@@ -179,8 +114,8 @@ export function createWhatsNewStepsConfig({
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToPageEditor();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
||||
@@ -193,8 +128,8 @@ export function createWhatsNewStepsConfig({
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToActiveFiles();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.WRAP_UP]: {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
||||
import { useInitialPageDocument } from "@app/components/pageEditor/hooks/useInitialPageDocument";
|
||||
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
|
||||
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 { useWheelZoom } from "@app/hooks/useWheelZoom";
|
||||
import { useEditedDocumentState } from "@app/components/pageEditor/hooks/useEditedDocumentState";
|
||||
@@ -168,6 +168,16 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
}, [pageEditorFiles]);
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Check if there are any PDF files in FileContext (bypasses fileOrder, which is
|
||||
// populated asynchronously via effect). Used to avoid a one-render flash of
|
||||
// "No PDF files loaded" when PageEditor first mounts after files are added.
|
||||
const hasPdfFiles = useMemo(() => {
|
||||
return state.files.ids.some((id) => {
|
||||
const stub = selectors.getStirlingFileStub(id);
|
||||
return stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
|
||||
});
|
||||
}, [state.files.ids, selectors]);
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
@@ -448,11 +458,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
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 activeFileIds = selectedFileIds;
|
||||
|
||||
usePageEditorRightRailButtons({
|
||||
usePageEditorWorkbenchBarButtons({
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
@@ -466,8 +475,6 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
onExportSelected,
|
||||
onSaveChanges: applyChanges,
|
||||
exportLoading,
|
||||
activeFileCount: activeFileIds.length,
|
||||
closePdf,
|
||||
});
|
||||
|
||||
// Export preview function - defined after export functions to avoid circular dependency
|
||||
@@ -677,23 +684,21 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
>
|
||||
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
||||
|
||||
{!initialDocument &&
|
||||
!globalProcessing &&
|
||||
selectedFileIds.length === 0 && (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
📄
|
||||
</Text>
|
||||
<Text c="dimmed">No PDF files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Add files to start editing pages
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
{!initialDocument && !globalProcessing && !hasPdfFiles && (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
📄
|
||||
</Text>
|
||||
<Text c="dimmed">No PDF files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Add files to start editing pages
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{!initialDocument && globalProcessing && (
|
||||
{!initialDocument && (globalProcessing || hasPdfFiles) && (
|
||||
<Box p={0}>
|
||||
<SkeletonLoader type="controls" />
|
||||
<SkeletonLoader type="pageGrid" count={8} />
|
||||
|
||||
@@ -25,7 +25,6 @@ interface PageEditorControlsProps {
|
||||
onPageBreak: () => void;
|
||||
onPageBreakAll: () => void;
|
||||
|
||||
// Export functions (moved to right rail)
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
|
||||
@@ -138,9 +137,7 @@ const PageEditorControls = ({
|
||||
disabled={!canUndo}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color: canUndo
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
color: canUndo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -154,9 +151,7 @@ const PageEditorControls = ({
|
||||
disabled={!canRedo}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color: canRedo
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
color: canRedo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -183,8 +178,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -200,8 +195,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -217,8 +212,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -234,8 +229,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
@@ -251,8 +246,8 @@ const PageEditorControls = ({
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
|
||||
@@ -27,19 +27,18 @@ export default function PageSelectByNumberButton({
|
||||
return (
|
||||
<Tooltip
|
||||
content={label}
|
||||
position="left"
|
||||
position="bottom"
|
||||
offset={12}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<div>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
+14
-34
@@ -1,13 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useRightRailButtons,
|
||||
RightRailButtonWithAction,
|
||||
} from "@app/hooks/useRightRailButtons";
|
||||
useWorkbenchBarButtons,
|
||||
WorkbenchBarButtonWithAction,
|
||||
} from "@app/hooks/useWorkbenchBarButtons";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||
|
||||
interface PageEditorRightRailButtonsParams {
|
||||
interface PageEditorWorkbenchBarButtonsParams {
|
||||
totalPages: number;
|
||||
selectedPageCount: number;
|
||||
csvInput: string;
|
||||
@@ -21,12 +21,10 @@ interface PageEditorRightRailButtonsParams {
|
||||
onExportSelected: () => void;
|
||||
onSaveChanges: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
}
|
||||
|
||||
export function usePageEditorRightRailButtons(
|
||||
params: PageEditorRightRailButtonsParams,
|
||||
export function usePageEditorWorkbenchBarButtons(
|
||||
params: PageEditorWorkbenchBarButtonsParams,
|
||||
) {
|
||||
const {
|
||||
totalPages,
|
||||
@@ -42,31 +40,27 @@ export function usePageEditorRightRailButtons(
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
} = params;
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const selectAllLabel = t("rightRail.selectAll", "Select All");
|
||||
const deselectAllLabel = t("rightRail.deselectAll", "Deselect All");
|
||||
const selectAllLabel = t("workbenchBar.selectAll", "Select All");
|
||||
const deselectAllLabel = t("workbenchBar.deselectAll", "Deselect All");
|
||||
const selectByNumberLabel = t(
|
||||
"rightRail.selectByNumber",
|
||||
"workbenchBar.selectByNumber",
|
||||
"Select by Page Numbers",
|
||||
);
|
||||
const deleteSelectedLabel = t(
|
||||
"rightRail.deleteSelected",
|
||||
"workbenchBar.deleteSelected",
|
||||
"Delete Selected Pages",
|
||||
);
|
||||
const exportSelectedLabel = t(
|
||||
"rightRail.exportSelected",
|
||||
"workbenchBar.exportSelected",
|
||||
"Export Selected Pages",
|
||||
);
|
||||
const saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
|
||||
const closePdfLabel = t("rightRail.closePdf", "Close PDF");
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
const saveChangesLabel = t("workbenchBar.saveChanges", "Save Changes");
|
||||
const buttons = useMemo<WorkbenchBarButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: "page-select-all",
|
||||
@@ -156,17 +150,6 @@ export function usePageEditorRightRailButtons(
|
||||
visible: totalPages > 0,
|
||||
onClick: onSaveChanges,
|
||||
},
|
||||
{
|
||||
id: "page-close-pdf",
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: closePdfLabel,
|
||||
ariaLabel: closePdfLabel,
|
||||
section: "top" as const,
|
||||
order: 60,
|
||||
disabled: activeFileCount === 0,
|
||||
visible: activeFileCount > 0,
|
||||
onClick: closePdf,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
t,
|
||||
@@ -177,7 +160,6 @@ export function usePageEditorRightRailButtons(
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
saveChangesLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
@@ -191,9 +173,7 @@ export function usePageEditorRightRailButtons(
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
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 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
|
||||
const configNavSections = useConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
handleCloseSync,
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
@@ -131,15 +146,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
return null;
|
||||
}, [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(
|
||||
async (key: NavKey) => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
|
||||
@@ -207,7 +207,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("storageShare.fileCount", "{{count}} files selected", {
|
||||
{t("storageShare.fileCount", "{{count}} files", {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -116,7 +116,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("storageUpload.fileCount", "{{count}} files selected", {
|
||||
{t("storageUpload.fileCount", "{{count}} files", {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
|
||||
interface CloudIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
colored?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Drive icon with brand-color hover support.
|
||||
* Pass `colored={true}` to render the official tri-color logo;
|
||||
* omit / pass `colored={false}` for a uniform muted/current-color version.
|
||||
*/
|
||||
export function GoogleDriveIcon({ colored, ...rest }: CloudIconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 87.3 78" width={18} height={18} {...rest}>
|
||||
<path
|
||||
d="M6.6 66.85l3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8H0c0 1.55.4 3.1 1.2 4.5z"
|
||||
fill={colored ? "#0066DA" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
<path
|
||||
d="M43.65 25L29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3L1.2 52.35c-.8 1.4-1.2 2.95-1.2 4.5h27.5z"
|
||||
fill={colored ? "#00AC47" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.4}
|
||||
/>
|
||||
<path
|
||||
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5H59.85L73.55 76.8z"
|
||||
fill={colored ? "#EA4335" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
<path
|
||||
d="M43.65 25L57.4 1.2c-1.35-.8-2.9-1.2-4.5-1.2H34.4c-1.6 0-3.15.45-4.5 1.2z"
|
||||
fill={colored ? "#00832D" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.45}
|
||||
/>
|
||||
<path
|
||||
d="M59.85 53H27.5l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
|
||||
fill={colored ? "#2684FC" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.55}
|
||||
/>
|
||||
<path
|
||||
d="M73.4 26.5L60.7 4.5c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25l16.2 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||
fill={colored ? "#FFBA00" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OneDrive icon with brand-color hover support.
|
||||
* FOR FUTURE USE — OneDrive integration is not yet implemented.
|
||||
*/
|
||||
export function OneDriveIcon({ colored, ...rest }: CloudIconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={18} height={18} {...rest}>
|
||||
<path
|
||||
d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"
|
||||
fill={colored ? "#0078D4" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropbox icon with brand-color hover support.
|
||||
* FOR FUTURE USE — Dropbox integration is not yet implemented.
|
||||
*/
|
||||
export function DropboxIcon({ colored, ...rest }: CloudIconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={18} height={18} {...rest}>
|
||||
<path
|
||||
d="M8.01 4.555L4.005 7.11 8.01 9.665 4.005 12.22 0 9.651l4.005-2.555L0 4.555 4.005 2 8.01 4.555zm-4.026 8.487l4.006-2.555 4.005 2.555-4.005 2.555-4.006-2.555zm4.026-3.39l4.005-2.556L8.01 4.555 11.995 2 16 4.555l-4.005 2.555L16 9.665l-4.005 2.555L8.01 9.652z"
|
||||
fill={colored ? "#0061FF" : "currentColor"}
|
||||
opacity={colored ? 1 : 0.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -7,21 +7,18 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Box,
|
||||
Image,
|
||||
ThemeIcon,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
|
||||
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
@@ -47,15 +44,15 @@ const FileCard = ({
|
||||
isSupported = true,
|
||||
}: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const { thumbnail: indexedDBThumb, isGenerating } =
|
||||
useIndexedDBThumbnail(fileStub);
|
||||
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
|
||||
const {
|
||||
isEncrypted,
|
||||
thumbnail: thumb,
|
||||
isGenerating,
|
||||
} = useFileThumbnail(fileStub);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Show loading state during hydration: PDF file without thumbnail yet
|
||||
const isPdf = file.type === "application/pdf";
|
||||
const isHydrating = isPdf && !thumb && !isGenerating;
|
||||
const isHydrating = isPdf && !isEncrypted && !thumb && !isGenerating;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -73,8 +70,7 @@ const FileCard = ({
|
||||
? "2px solid var(--mantine-color-blue-6)"
|
||||
: undefined,
|
||||
backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined,
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
filter: isSupported ? "none" : "grayscale(50%)",
|
||||
opacity: 1,
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@@ -146,51 +142,13 @@ const FileCard = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{thumb ? (
|
||||
<Image
|
||||
src={thumb}
|
||||
alt="PDF thumbnail"
|
||||
height={110}
|
||||
width={80}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
) : isGenerating || isHydrating ? (
|
||||
<Stack align="center" justify="center" gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading...
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
{file.size > 100 * 1024 * 1024 && (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Large File
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
thumbnail={thumb || undefined}
|
||||
isEncrypted={isEncrypted}
|
||||
isLoading={isGenerating || isHydrating}
|
||||
iconSize="3rem"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
export type FileDocVariant =
|
||||
| "pdf"
|
||||
| "spreadsheet"
|
||||
| "doc"
|
||||
| "image"
|
||||
| "archive"
|
||||
| "code"
|
||||
| "generic";
|
||||
|
||||
export const VARIANT_COLORS: Record<FileDocVariant, string> = {
|
||||
pdf: "#DC2626",
|
||||
spreadsheet: "#16a34a",
|
||||
doc: "#2563eb",
|
||||
image: "#7c3aed",
|
||||
archive: "#ea580c",
|
||||
code: "#0891b2",
|
||||
generic: "#71717a",
|
||||
};
|
||||
|
||||
export function FileDocIcon({
|
||||
color,
|
||||
variant,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
color?: string;
|
||||
variant: FileDocVariant;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
color ??= VARIANT_COLORS[variant];
|
||||
const foldX = 10.5;
|
||||
const foldY = 5.5;
|
||||
|
||||
let interior: React.ReactNode;
|
||||
if (variant === "spreadsheet") {
|
||||
// Outer border of the grid
|
||||
const gx1 = 3,
|
||||
gy1 = 8,
|
||||
gx2 = 13,
|
||||
gy2 = 17;
|
||||
// Column divider x, row divider y positions
|
||||
const colX = 8;
|
||||
const row1Y = 11,
|
||||
row2Y = 14;
|
||||
interior = (
|
||||
<g opacity="0.7" stroke={color} strokeWidth="1" fill="none">
|
||||
<rect x={gx1} y={gy1} width={gx2 - gx1} height={gy2 - gy1} rx="0.5" />
|
||||
<line x1={colX} y1={gy1} x2={colX} y2={gy2} />
|
||||
<line x1={gx1} y1={row1Y} x2={gx2} y2={row1Y} />
|
||||
<line x1={gx1} y1={row2Y} x2={gx2} y2={row2Y} />
|
||||
</g>
|
||||
);
|
||||
} else if (variant === "image") {
|
||||
interior = (
|
||||
<>
|
||||
<polyline
|
||||
points="2.5,16 5.5,11.5 8,14 10,12 13.5,16"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="9.5"
|
||||
r="1.3"
|
||||
stroke={color}
|
||||
strokeWidth="1"
|
||||
opacity="0.6"
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (variant === "code") {
|
||||
interior = (
|
||||
<>
|
||||
<polyline
|
||||
points="6,9.5 3.5,12.5 6,15.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
opacity="0.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="10,9.5 12.5,12.5 10,15.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
opacity="0.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (variant === "archive") {
|
||||
interior = (
|
||||
<>
|
||||
<line
|
||||
x1="5"
|
||||
y1="9.5"
|
||||
x2="11"
|
||||
y2="9.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="12"
|
||||
x2="11"
|
||||
y2="12"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="14.5"
|
||||
x2="11"
|
||||
y2="14.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
// pdf, doc, generic — text lines
|
||||
interior = (
|
||||
<>
|
||||
<line
|
||||
x1="3.5"
|
||||
y1="9.5"
|
||||
x2="12.5"
|
||||
y2="9.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3.5"
|
||||
y1="12"
|
||||
x2="12.5"
|
||||
y2="12"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3.5"
|
||||
y1="14.5"
|
||||
x2="8.5"
|
||||
y2="14.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
style={{ width: 16, height: 20, ...style }}
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Document outline with top-right page fold */}
|
||||
<path
|
||||
d={`M 1.5,1 H ${foldX} L 15,${foldY} V 18.5 Q 15,19 14.5,19 H 1.5 Q 1,19 1,18.5 V 1.5 Q 1,1 1.5,1 Z`}
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Fold corner triangle */}
|
||||
<path
|
||||
d={`M ${foldX},1 L ${foldX},${foldY} L 15,${foldY} Z`}
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
fill={color}
|
||||
fillOpacity="0.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{interior}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
backgroundColor: "var(--right-rail-bg)",
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Image,
|
||||
Badge,
|
||||
ThemeIcon,
|
||||
SimpleGrid,
|
||||
} from "@mantine/core";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
|
||||
@@ -200,26 +198,23 @@ const FilePickerModal = ({
|
||||
height: 80,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "var(--mantine-color-gray-0)",
|
||||
backgroundColor: "white",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{file.thumbnail ? (
|
||||
<Image
|
||||
src={file.thumbnail}
|
||||
alt="PDF thumbnail"
|
||||
height={70}
|
||||
width={50}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<ThemeIcon variant="light" color="red" size={40}>
|
||||
<PictureAsPdfIcon style={{ fontSize: 24 }} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
thumbnail={
|
||||
file.processedFile?.isEncrypted
|
||||
? undefined
|
||||
: file.thumbnail
|
||||
}
|
||||
isEncrypted={Boolean(
|
||||
file.processedFile?.isEncrypted,
|
||||
)}
|
||||
iconSize="2rem"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* File info */}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/* ── Trigger ─────────────────────────────────────── */
|
||||
.trigger {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-surface);
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.trigger:hover:not(.triggerDisabled) {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.triggerDisabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Popover dropdown ────────────────────────────── */
|
||||
.dropdown {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md) !important;
|
||||
min-width: 340px;
|
||||
}
|
||||
|
||||
/* ── Header area (tabs + sort) ───────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0.45rem;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.tabGroup {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One slim row: Saved files | Workbench | Upload (text only, no icon) */
|
||||
.slimTabBar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
max-height: 26px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-default-hover) 85%,
|
||||
transparent
|
||||
);
|
||||
padding: 1px;
|
||||
gap: 1px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.slimTab,
|
||||
.slimTabActive {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 1px 0.35rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
background 0.1s ease,
|
||||
color 0.1s ease;
|
||||
}
|
||||
|
||||
.slimTab {
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.slimTab:hover {
|
||||
color: var(--mantine-color-text);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 60%, transparent);
|
||||
}
|
||||
|
||||
.slimTabActive {
|
||||
background: var(--mantine-color-blue-filled);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.slimTabUpload {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
padding: 1px 0.45rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.slimTabUpload:hover:not(:disabled) {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
|
||||
}
|
||||
|
||||
.slimTabUpload:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sort button group ───────────────────────────── */
|
||||
.sortGroup {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.sortBtn {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.6;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortBtn:hover {
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.sortBtnActive {
|
||||
background: var(--mantine-color-blue-filled);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sortArrow {
|
||||
opacity: 0.85;
|
||||
font-size: 0.65rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Search / filter row ─────────────────────────── */
|
||||
.searchRow {
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 1.625rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
border: 1px solid var(--border-subtle, var(--border-default));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--mantine-color-default-hover);
|
||||
color: var(--mantine-color-text);
|
||||
outline: none;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
/* hide browser clear button in Safari */
|
||||
.searchInput::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* ── Scrollable file list ────────────────────────── */
|
||||
.list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Individual file row ─────────────────────────── */
|
||||
.fileItem {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-default);
|
||||
text-align: left;
|
||||
transition: background 0.08s ease;
|
||||
}
|
||||
|
||||
.fileItem:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.fileItem:hover:not(:disabled) {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.fileItem:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.fileItemContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Empty / loading states ──────────────────────── */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Box, Popover, ScrollArea, Text, Loader } from "@mantine/core";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createStirlingFile,
|
||||
createFileId,
|
||||
createNewStirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import {
|
||||
parseContentDispositionFilename,
|
||||
extractLatestFilesFromBundle,
|
||||
} from "@app/services/shareBundleUtils";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
import styles from "@app/components/shared/FileSelectorPicker.module.css";
|
||||
import "@app/components/shared/FileSidebarFileItem.css";
|
||||
|
||||
const LS_TAB = "filePicker.tab";
|
||||
const LS_SORT = "filePicker.sort";
|
||||
const LS_SORT_DIR = "filePicker.sortDir";
|
||||
|
||||
function lsGet<const T extends readonly string[]>(
|
||||
key: string,
|
||||
fallback: T[number],
|
||||
valid: T,
|
||||
): T[number] {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
const allowed = valid as readonly string[];
|
||||
if (v && allowed.includes(v)) return v as T[number];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function lsSet(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(ms: number | undefined): string {
|
||||
if (!ms) return "";
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function buildMeta(stub: StirlingFileStub): string {
|
||||
const parts: string[] = [];
|
||||
const pages = stub.processedFile?.totalPages;
|
||||
if (pages) parts.push(`${pages} ${pages === 1 ? "page" : "pages"}`);
|
||||
const size = formatBytes(stub.size ?? 0);
|
||||
if (size) parts.push(size);
|
||||
const date = formatDate(stub.lastModified || stub.createdAt);
|
||||
if (date) parts.push(date);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export interface FileSelectorResult {
|
||||
stub: StirlingFileStub;
|
||||
stirlingFile: StirlingFile;
|
||||
}
|
||||
|
||||
export interface FileSelectorPickerProps {
|
||||
placeholder?: string;
|
||||
/** FileIds to hide from both lists (e.g. the other slot's current selection) */
|
||||
excludeIds?: string[];
|
||||
disabled?: boolean;
|
||||
/** Optional data-testid applied to the trigger box */
|
||||
testId?: string;
|
||||
/**
|
||||
* Called with the stub (for display) and the ready-to-use StirlingFile (for processing).
|
||||
* Files are NOT added to the workbench — data is loaded inline.
|
||||
*/
|
||||
onSelect: (result: FileSelectorResult) => void;
|
||||
}
|
||||
|
||||
export function FileSelectorPicker({
|
||||
placeholder,
|
||||
excludeIds = [],
|
||||
disabled = false,
|
||||
testId,
|
||||
onSelect,
|
||||
}: FileSelectorPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"workbench" | "saved">(() =>
|
||||
lsGet(LS_TAB, "saved", ["workbench", "saved"]),
|
||||
);
|
||||
const [sortBy, setSortBy] = useState<"date" | "name">(() =>
|
||||
lsGet(LS_SORT, "date", ["date", "name"]),
|
||||
);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">(() =>
|
||||
lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]),
|
||||
);
|
||||
const [savedStubs, setSavedStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [savedLoading, setSavedLoading] = useState(false);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hoveredStub, setHoveredStub] = useState<{
|
||||
rect: DOMRect;
|
||||
stub: StirlingFileStub;
|
||||
} | null>(null);
|
||||
const [hoveredThumbnail, setHoveredThumbnail] = useState<string | null>(null);
|
||||
const thumbCancelRef = useRef<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { fileStubs: workbenchStubs } = useAllFiles();
|
||||
const indexedDB = useIndexedDB();
|
||||
const { selectors } = useFileContext();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
|
||||
// Load thumbnail lazily when hovering over a file row
|
||||
useEffect(() => {
|
||||
if (!hoveredStub) {
|
||||
setHoveredThumbnail(null);
|
||||
return;
|
||||
}
|
||||
if (hoveredStub.stub.thumbnailUrl) {
|
||||
setHoveredThumbnail(hoveredStub.stub.thumbnailUrl);
|
||||
return;
|
||||
}
|
||||
thumbCancelRef.current = false;
|
||||
setHoveredThumbnail(null);
|
||||
(async () => {
|
||||
try {
|
||||
const file = await indexedDB.loadFile(hoveredStub.stub.id as FileId);
|
||||
if (!file || thumbCancelRef.current) return;
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (thumbCancelRef.current || !thumbnail) return;
|
||||
setHoveredThumbnail(thumbnail);
|
||||
void indexedDB.updateThumbnail(
|
||||
hoveredStub.stub.id as FileId,
|
||||
thumbnail,
|
||||
);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
thumbCancelRef.current = true;
|
||||
};
|
||||
}, [hoveredStub, indexedDB]);
|
||||
|
||||
const handleTabChange = useCallback((tab: "workbench" | "saved") => {
|
||||
lsSet(LS_TAB, tab);
|
||||
setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(sort: "date" | "name") => {
|
||||
if (sort === sortBy) {
|
||||
const newDir = sortDir === "asc" ? "desc" : "asc";
|
||||
lsSet(LS_SORT_DIR, newDir);
|
||||
setSortDir(newDir);
|
||||
} else {
|
||||
const defaultDir: "asc" | "desc" = sort === "name" ? "asc" : "desc";
|
||||
lsSet(LS_SORT, sort);
|
||||
lsSet(LS_SORT_DIR, defaultDir);
|
||||
setSortBy(sort);
|
||||
setSortDir(defaultDir);
|
||||
}
|
||||
},
|
||||
[sortBy, sortDir],
|
||||
);
|
||||
|
||||
// Sync tab/sort from localStorage whenever this picker opens.
|
||||
// Both slot pickers mount simultaneously so their useState initialisers run at
|
||||
// the same time — a tab change in one picker writes to localStorage but the
|
||||
// other picker's React state is stale. Re-reading on open fixes that.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setActiveTab(lsGet(LS_TAB, "saved", ["workbench", "saved"]));
|
||||
setSortBy(lsGet(LS_SORT, "date", ["date", "name"]));
|
||||
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
|
||||
}, [isOpen]);
|
||||
|
||||
// Load saved files when the saved tab is active and the picker is open
|
||||
useEffect(() => {
|
||||
if (activeTab !== "saved" || !isOpen) return;
|
||||
setSavedLoading(true);
|
||||
loadRecentFiles()
|
||||
.then(setSavedStubs)
|
||||
.finally(() => setSavedLoading(false));
|
||||
}, [activeTab, isOpen, loadRecentFiles]);
|
||||
|
||||
const workbenchIdSet = useMemo(
|
||||
() => new Set(workbenchStubs.map((s) => s.id)),
|
||||
[workbenchStubs],
|
||||
);
|
||||
|
||||
const displayStubs = useMemo(() => {
|
||||
const base = activeTab === "workbench" ? workbenchStubs : savedStubs;
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const filtered = base.filter(
|
||||
(s) =>
|
||||
!excludeIds.includes(s.id) && (!q || s.name.toLowerCase().includes(q)),
|
||||
);
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
return [...filtered].sort((a, b) =>
|
||||
sortBy === "name"
|
||||
? dir * a.name.localeCompare(b.name)
|
||||
: dir *
|
||||
((a.lastModified || a.createdAt || 0) -
|
||||
(b.lastModified || b.createdAt || 0)),
|
||||
);
|
||||
}, [
|
||||
activeTab,
|
||||
workbenchStubs,
|
||||
savedStubs,
|
||||
excludeIds,
|
||||
sortBy,
|
||||
sortDir,
|
||||
searchQuery,
|
||||
]);
|
||||
|
||||
const loadAndSelect = useCallback(
|
||||
async (stub: StirlingFileStub) => {
|
||||
if (loadingId) return;
|
||||
|
||||
// Workbench file — get StirlingFile directly from FileContext (no loading needed)
|
||||
if (workbenchIdSet.has(stub.id)) {
|
||||
const sf = selectors.getFile(stub.id as FileId);
|
||||
if (sf) {
|
||||
// Prefer the workbench stub (has thumbnail) over the saved stub (may not)
|
||||
const workbenchStub =
|
||||
selectors.getStirlingFileStub(stub.id as FileId) ?? stub;
|
||||
onSelect({ stub: workbenchStub, stirlingFile: sf });
|
||||
setIsOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Saved file — load bytes without touching the workbench
|
||||
setLoadingId(stub.id);
|
||||
try {
|
||||
let stirlingFile: StirlingFile | null = null;
|
||||
|
||||
if (stub.remoteShareToken) {
|
||||
const res = await apiClient.get(
|
||||
`/api/v1/storage/share-links/${stub.remoteShareToken}`,
|
||||
{
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const ct =
|
||||
res.headers?.["content-type"] ||
|
||||
res.headers?.["Content-Type"] ||
|
||||
"";
|
||||
const disp =
|
||||
res.headers?.["content-disposition"] ||
|
||||
res.headers?.["Content-Disposition"] ||
|
||||
"";
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || "shared-file",
|
||||
ct,
|
||||
);
|
||||
if (files[0])
|
||||
stirlingFile = createStirlingFile(files[0], createFileId());
|
||||
} else if (stub.remoteStorageId) {
|
||||
const res = await apiClient.get(
|
||||
`/api/v1/storage/files/${stub.remoteStorageId}/download`,
|
||||
{
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const ct =
|
||||
res.headers?.["content-type"] ||
|
||||
res.headers?.["Content-Type"] ||
|
||||
"";
|
||||
const disp =
|
||||
res.headers?.["content-disposition"] ||
|
||||
res.headers?.["Content-Disposition"] ||
|
||||
"";
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || stub.name,
|
||||
ct,
|
||||
);
|
||||
if (files[0])
|
||||
stirlingFile = createStirlingFile(files[0], stub.id as FileId);
|
||||
} else {
|
||||
// Local IndexedDB file
|
||||
const localFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (localFile) stirlingFile = localFile;
|
||||
}
|
||||
|
||||
if (stirlingFile) {
|
||||
// Generate thumbnail on-the-fly if the stub doesn't already have one
|
||||
let resolvedStub = stub;
|
||||
if (!resolvedStub.thumbnailUrl) {
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(stirlingFile);
|
||||
if (thumbnail) {
|
||||
resolvedStub = { ...stub, thumbnailUrl: thumbnail };
|
||||
// Persist so subsequent opens don't regenerate
|
||||
void fileStorage.updateThumbnail(
|
||||
stirlingFile.fileId as FileId,
|
||||
thumbnail,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — thumbnail simply won't show
|
||||
}
|
||||
}
|
||||
onSelect({ stub: resolvedStub, stirlingFile });
|
||||
setIsOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("FileSelectorPicker: failed to load file", err);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[loadingId, workbenchIdSet, selectors, onSelect],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file || uploadBusy || disabled) return;
|
||||
setUploadBusy(true);
|
||||
try {
|
||||
const id = createFileId();
|
||||
let stub = createNewStirlingFileStub(file, id);
|
||||
const stirlingFile = createStirlingFile(file, id);
|
||||
// Generate a first-page thumbnail for the uploaded file
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (thumbnail) stub = { ...stub, thumbnailUrl: thumbnail };
|
||||
} catch {
|
||||
// Non-fatal — thumbnail simply won't show
|
||||
}
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
lsSet(LS_TAB, "saved");
|
||||
setActiveTab("saved");
|
||||
const refreshed = await loadRecentFiles();
|
||||
setSavedStubs(refreshed);
|
||||
onSelect({ stub, stirlingFile });
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
console.error("FileSelectorPicker: upload failed", err);
|
||||
} finally {
|
||||
setUploadBusy(false);
|
||||
}
|
||||
},
|
||||
[disabled, loadRecentFiles, onSelect, uploadBusy],
|
||||
);
|
||||
|
||||
const triggerClass = [
|
||||
styles.trigger,
|
||||
disabled ? styles.triggerDisabled : "",
|
||||
isOpen ? styles.triggerOpen : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
opened={isOpen}
|
||||
onChange={setIsOpen}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
position="bottom-start"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
closeOnClickOutside
|
||||
clickOutsideEvents={["mousedown", "touchstart"]}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Box
|
||||
className={triggerClass}
|
||||
style={{ width: "100%" }}
|
||||
data-testid={testId}
|
||||
onClick={() => {
|
||||
if (!disabled) setIsOpen((o) => !o);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(e) => {
|
||||
if (!disabled && (e.key === "Enter" || e.key === " "))
|
||||
setIsOpen((o) => !o);
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{placeholder ||
|
||||
t("fileSelectorPicker.placeholder", "Select file")}
|
||||
</Text>
|
||||
<AddIcon
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown className={styles.dropdown}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.tabGroup}>
|
||||
<div
|
||||
className={styles.slimTabBar}
|
||||
role="group"
|
||||
aria-label={t(
|
||||
"fileSelectorPicker.tabListLabel",
|
||||
"Saved files, workbench, upload",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "saved"}
|
||||
className={
|
||||
activeTab === "saved"
|
||||
? styles.slimTabActive
|
||||
: styles.slimTab
|
||||
}
|
||||
onClick={() => handleTabChange("saved")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.saved", "Saved files")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "workbench"}
|
||||
className={
|
||||
activeTab === "workbench"
|
||||
? styles.slimTabActive
|
||||
: styles.slimTab
|
||||
}
|
||||
onClick={() => handleTabChange("workbench")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="file-selector-upload-btn"
|
||||
className={styles.slimTabUpload}
|
||||
aria-label={t(
|
||||
"fileSelectorPicker.upload",
|
||||
"Upload from computer",
|
||||
)}
|
||||
title={t("fileSelectorPicker.upload", "Upload from computer")}
|
||||
disabled={disabled || uploadBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploadBusy ? (
|
||||
<Loader size={11} />
|
||||
) : (
|
||||
t("fileSelectorPicker.tabs.upload", "Upload")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sortGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.sortBtn,
|
||||
sortBy === "date" ? styles.sortBtnActive : "",
|
||||
].join(" ")}
|
||||
onClick={() => handleSortChange("date")}
|
||||
title={
|
||||
sortDir === "desc"
|
||||
? t("fileSelectorPicker.sort.dateDesc", "Newest first")
|
||||
: t("fileSelectorPicker.sort.dateAsc", "Oldest first")
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
|
||||
{sortBy === "date" && (
|
||||
<span className={styles.sortArrow}>
|
||||
{sortDir === "desc" ? " ↓" : " ↑"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.sortBtn,
|
||||
sortBy === "name" ? styles.sortBtnActive : "",
|
||||
].join(" ")}
|
||||
onClick={() => handleSortChange("name")}
|
||||
title={
|
||||
sortDir === "asc"
|
||||
? t("fileSelectorPicker.sort.nameAsc", "A to Z")
|
||||
: t("fileSelectorPicker.sort.nameDesc", "Z to A")
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.nameLabel", "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;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
dataTour?: string;
|
||||
}
|
||||
|
||||
interface HoverActionMenuProps {
|
||||
@@ -18,6 +19,7 @@ interface HoverActionMenuProps {
|
||||
actions: HoverAction[];
|
||||
position?: "inside" | "outside";
|
||||
className?: string;
|
||||
visibility?: "state" | "cssHover";
|
||||
}
|
||||
|
||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
@@ -25,6 +27,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
actions,
|
||||
position = "inside",
|
||||
className = "",
|
||||
visibility = "state",
|
||||
}) => {
|
||||
const visibleActions = actions.filter((action) => !action.hidden);
|
||||
|
||||
@@ -32,10 +35,23 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = { zIndex: Z_INDEX_HOVER_ACTION_MENU };
|
||||
if (visibility === "state") {
|
||||
style.opacity = show ? 1 : 0;
|
||||
style.pointerEvents = show ? "auto" : "none";
|
||||
} else if (show) {
|
||||
// Force visible (e.g. mobile) even when CSS-hover mode is active.
|
||||
style.opacity = 1;
|
||||
style.pointerEvents = "auto";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === "outside" ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
|
||||
style={style}
|
||||
data-hover-action-menu="true"
|
||||
data-hover-action-menu-mode={visibility}
|
||||
data-force-visible={show ? "true" : "false"}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -48,7 +64,8 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
style={{ color: action.color || "var(--right-rail-icon)" }}
|
||||
style={{ color: action.color || "var(--text-secondary)" }}
|
||||
data-tour={action.dataTour}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -50,6 +50,7 @@ interface InfoBannerProps {
|
||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +76,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
buttonVariant = "light",
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
@@ -85,13 +87,21 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.2rem";
|
||||
const textSize = compact ? "xs" : "sm";
|
||||
const buttonSize = compact ? "xs" : "xs";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
border: "none",
|
||||
borderBottom:
|
||||
borderColor === "transparent"
|
||||
? "none"
|
||||
: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -105,22 +115,22 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
gap={compact ? "xs" : "sm"}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
size={textSize}
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
>
|
||||
{title}
|
||||
@@ -128,9 +138,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size="sm"
|
||||
size={textSize}
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
lineClamp={2}
|
||||
lineClamp={compact ? 1 : 2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
@@ -141,11 +151,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
color={buttonColor ?? toneStyle.buttonColor}
|
||||
size="xs"
|
||||
size={buttonSize}
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
<LocalIcon
|
||||
icon={buttonIcon}
|
||||
width={compact ? "0.75rem" : "0.9rem"}
|
||||
height={compact ? "0.75rem" : "0.9rem"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
@@ -160,7 +174,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
aria-label="Dismiss"
|
||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width={compact ? "0.85rem" : "1rem"}
|
||||
height={compact ? "0.85rem" : "1rem"}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -5,14 +5,11 @@
|
||||
|
||||
/* ── Hero text ───────────────────────────────────────────── */
|
||||
.landing-title {
|
||||
margin: 0;
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 2.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin: 1.75rem auto 0.5rem;
|
||||
height: 3rem;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.landing-subtitle {
|
||||
|
||||
@@ -8,6 +8,7 @@ import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
||||
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
|
||||
import { LandingActions } from "@app/components/shared/LandingActions";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import "@app/components/shared/LandingPage.css";
|
||||
|
||||
const LandingPage = () => {
|
||||
@@ -79,9 +80,10 @@ const LandingPage = () => {
|
||||
>
|
||||
<LandingDocumentStack />
|
||||
|
||||
<h1 className="landing-title">
|
||||
{t("landing.heroTitle", "Stirling PDF")}
|
||||
</h1>
|
||||
<Wordmark
|
||||
alt={t("landing.heroTitle", "Stirling PDF")}
|
||||
className="landing-title"
|
||||
/>
|
||||
<p className="landing-subtitle">
|
||||
{t(
|
||||
"landing.heroSubtitle",
|
||||
|
||||
@@ -91,10 +91,10 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
||||
: "transparent",
|
||||
color: disabled
|
||||
? "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))"
|
||||
? "var(--text-muted)"
|
||||
: isSelected
|
||||
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
|
||||
: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))",
|
||||
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-3))"
|
||||
: "var(--text-primary)",
|
||||
transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
"&:hover": !disabled
|
||||
@@ -274,11 +274,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
data-testid="language-selector-button"
|
||||
title={!opened && tooltip ? tooltip : undefined}
|
||||
styles={{
|
||||
root: {
|
||||
color: "var(--right-rail-icon)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
"light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
|
||||
@@ -292,14 +292,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
data-testid="language-selector-button"
|
||||
leftSection={
|
||||
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
border: "none",
|
||||
color:
|
||||
"light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))",
|
||||
color: "var(--text-primary)",
|
||||
transition:
|
||||
"background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
"&:hover": {
|
||||
|
||||
@@ -33,11 +33,11 @@ const MultiSelectControls = ({
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{selectedCount} {t("fileManager.filesSelected", "files selected")}
|
||||
{selectedCount} {t("fileManager.filesSelected", "files")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button size="xs" variant="light" onClick={onClearSelection}>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
{t("fileManager.clearSelection", "Clear files")}
|
||||
</Button>
|
||||
|
||||
{onAddToUpload && (
|
||||
|
||||
@@ -197,7 +197,7 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
<Menu.Dropdown
|
||||
className="ph-no-capture"
|
||||
style={{
|
||||
backgroundColor: "var(--right-rail-bg)",
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
borderRadius: "8px",
|
||||
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;
|
||||
/** Focus event handler */
|
||||
onFocus?: () => void;
|
||||
/** Allow the icon to receive pointer events (e.g. when icon is a clickable button) */
|
||||
iconClickable?: boolean;
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
@@ -56,6 +58,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
readOnly = false,
|
||||
"aria-label": ariaLabel,
|
||||
onFocus,
|
||||
iconClickable = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -76,7 +79,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
{icon && (
|
||||
<span
|
||||
className={styles.icon}
|
||||
style={{ color: "var(--search-text-and-icon-color)" }}
|
||||
style={{
|
||||
pointerEvents: iconClickable ? "auto" : "none",
|
||||
left: "12px",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
@@ -96,8 +102,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
aria-label={ariaLabel}
|
||||
onFocus={onFocus}
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
color: "var(--search-text-and-icon-color)",
|
||||
paddingRight: shouldShowClearButton ? "40px" : "12px",
|
||||
paddingLeft: icon ? "40px" : "12px",
|
||||
}}
|
||||
@@ -108,7 +112,6 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={handleClear}
|
||||
style={{ color: "var(--search-text-and-icon-color)" }}
|
||||
aria-label="Clear input"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ActionIcon, Slider } from "@mantine/core";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||
|
||||
/**
|
||||
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
|
||||
*/
|
||||
export function ViewerInlineControls() {
|
||||
const { workbench } = useNavigationState();
|
||||
const viewer = useViewer();
|
||||
|
||||
const [zoomPercent, setZoomPercent] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
const zoomState = viewer.getZoomState();
|
||||
setZoomPercent(zoomState.zoomPercent || 100);
|
||||
|
||||
const unregister = viewer.registerImmediateZoomUpdate((pct) => {
|
||||
setZoomPercent(pct);
|
||||
});
|
||||
return () => unregister?.();
|
||||
}, [viewer.registerImmediateZoomUpdate]);
|
||||
|
||||
if (workbench !== "viewer") return null;
|
||||
|
||||
const sliderValue = Math.min(Math.max(zoomPercent, 20), 500);
|
||||
|
||||
return (
|
||||
<div className="viewer-inline-controls">
|
||||
{/* Divider */}
|
||||
<div className="workbench-bar-divider" />
|
||||
|
||||
{/* Zoom controls */}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => viewer.zoomActions.zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
<div className="viewer-inline-controls__slider-wrap">
|
||||
<Slider
|
||||
value={sliderValue}
|
||||
min={20}
|
||||
max={500}
|
||||
step={5}
|
||||
onChange={(val) => {
|
||||
viewer.zoomActions.setZoomLevel?.(val / 100);
|
||||
}}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: { width: "6rem" },
|
||||
thumb: { width: 14, height: 14 },
|
||||
track: { height: 3 },
|
||||
}}
|
||||
label={null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => viewer.zoomActions.zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomInIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
<span className="viewer-inline-controls__zoom-pct">
|
||||
{Math.round(zoomPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -34,6 +35,7 @@ export const useConfigNavSections = (
|
||||
_isAdmin: boolean = false,
|
||||
_runningEE: boolean = false,
|
||||
_loginEnabled: boolean = false,
|
||||
onRequestClose: () => void = () => {},
|
||||
): ConfigNavSection[] => {
|
||||
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;
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import LanguageSelector from "@app/components/shared/LanguageSelector";
|
||||
import type { ToolPanelMode } from "@app/constants/toolPanel";
|
||||
import type {
|
||||
StartupView,
|
||||
@@ -47,6 +49,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
|
||||
preferences.autoUnzipFileLimit,
|
||||
);
|
||||
@@ -355,6 +358,67 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Appearance */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.theme", "Theme")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.general.themeDescription",
|
||||
"Switch between light and dark mode",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={themeMode === "rainbow" ? "dark" : themeMode}
|
||||
onChange={(val) => {
|
||||
if ((themeMode === "dark") !== (val === "dark")) toggleTheme();
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
label: t("settings.general.themeLight", "Light"),
|
||||
value: "light",
|
||||
},
|
||||
{
|
||||
label: t("settings.general.themeDark", "Dark"),
|
||||
value: "dark",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.language", "Language")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.general.languageDescription",
|
||||
"Choose the display language",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<LanguageSelector position="bottom-end" offset={6} />
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
@@ -412,7 +476,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.general.defaultStartupViewDescription",
|
||||
"Choose which tab is active in the left column when the app starts",
|
||||
"Choose which view is active when the app starts",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"help",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import React from "react";
|
||||
import { Box, Center, Image } from "@mantine/core";
|
||||
import { Box, Center, Loader, Stack, Text } from "@mantine/core";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import { getFileTypeIcon } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
|
||||
export interface DocumentThumbnailProps {
|
||||
file: File | StirlingFileStub | null;
|
||||
thumbnail?: string | null;
|
||||
isEncrypted?: boolean;
|
||||
isLoading?: boolean;
|
||||
iconSize?: string | number;
|
||||
imgClassName?: string;
|
||||
onImageError?: React.ReactEventHandler<HTMLImageElement>;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
@@ -15,14 +22,19 @@ export interface DocumentThumbnailProps {
|
||||
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
file,
|
||||
thumbnail,
|
||||
isEncrypted = false,
|
||||
isLoading = false,
|
||||
iconSize = "4rem",
|
||||
imgClassName,
|
||||
onImageError,
|
||||
style = {},
|
||||
onClick,
|
||||
children,
|
||||
}) => {
|
||||
if (!file) return null;
|
||||
|
||||
const containerStyle = {
|
||||
position: "relative" as const,
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "relative",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
transition: "opacity 0.2s ease",
|
||||
width: "100%",
|
||||
@@ -33,20 +45,29 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
...style,
|
||||
};
|
||||
|
||||
if (thumbnail) {
|
||||
if (thumbnail && !isEncrypted) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<PrivateContent>
|
||||
<Image
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={`Preview of ${file.name}`}
|
||||
fit="contain"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
}}
|
||||
className={imgClassName}
|
||||
style={
|
||||
imgClassName
|
||||
? undefined
|
||||
: {
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
}
|
||||
}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={onImageError}
|
||||
/>
|
||||
</PrivateContent>
|
||||
{children}
|
||||
@@ -54,17 +75,93 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "calc(100% - 8px)",
|
||||
height: "calc(100% - 8px)",
|
||||
gap: "0.5rem",
|
||||
border: "2px dashed var(--mantine-color-red-5)",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
<LockIcon
|
||||
style={{ fontSize: iconSize, color: "var(--mantine-color-red-6)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
background: "rgba(220,38,38,0.1)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
Locked
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
</Text>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = detectFileExtension(file.name ?? "").toUpperCase();
|
||||
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
borderRadius: "0.25rem",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<PrivateContent>{getFileTypeIcon(file)}</PrivateContent>
|
||||
<PrivateContent>{getFileTypeIcon(file, iconSize)}</PrivateContent>
|
||||
{ext && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--text-secondary)",
|
||||
background: "rgb(var(--border))",
|
||||
padding: "3px 10px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
)}
|
||||
</Center>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
@@ -1,17 +1,84 @@
|
||||
import React from "react";
|
||||
import JavascriptIcon from "@mui/icons-material/Javascript";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import type { FileDocVariant } from "@app/components/shared/FileDocIcon";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
|
||||
export const SPREADSHEET_EXTS = new Set(["csv", "xls", "xlsx", "ods"]);
|
||||
export const DOC_EXTS = new Set([
|
||||
"md",
|
||||
"markdown",
|
||||
"txt",
|
||||
"doc",
|
||||
"docx",
|
||||
"odt",
|
||||
"rtf",
|
||||
]);
|
||||
export const IMAGE_EXTS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"bmp",
|
||||
"tiff",
|
||||
"tif",
|
||||
]);
|
||||
export const ARCHIVE_EXTS = new Set([
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"rar",
|
||||
"7z",
|
||||
"cbz",
|
||||
"cbr",
|
||||
]);
|
||||
export const CODE_EXTS = new Set([
|
||||
"js",
|
||||
"ts",
|
||||
"jsx",
|
||||
"tsx",
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
]);
|
||||
|
||||
type FileLike = File | StirlingFileStub;
|
||||
|
||||
/**
|
||||
* Returns an appropriate file type icon for the provided file.
|
||||
* - Uses the real file type and extension to decide the icon.
|
||||
* - No any-casts; accepts File or StirlingFileStub.
|
||||
*/
|
||||
export function getFileDocVariant(ext: string, mime = ""): FileDocVariant {
|
||||
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||
if (
|
||||
SPREADSHEET_EXTS.has(ext) ||
|
||||
mime.includes("spreadsheet") ||
|
||||
mime === "text/csv"
|
||||
)
|
||||
return "spreadsheet";
|
||||
if (
|
||||
DOC_EXTS.has(ext) ||
|
||||
mime === "text/markdown" ||
|
||||
mime === "text/plain" ||
|
||||
mime.includes("word") ||
|
||||
mime.includes("opendocument.text")
|
||||
)
|
||||
return "doc";
|
||||
if (IMAGE_EXTS.has(ext) || mime.startsWith("image/")) return "image";
|
||||
if (ARCHIVE_EXTS.has(ext) || mime.includes("zip") || mime.includes("archive"))
|
||||
return "archive";
|
||||
if (
|
||||
CODE_EXTS.has(ext) ||
|
||||
mime.includes("javascript") ||
|
||||
mime.includes("json") ||
|
||||
mime.includes("xml") ||
|
||||
mime === "text/html"
|
||||
)
|
||||
return "code";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
export function getFileTypeIcon(
|
||||
file: FileLike,
|
||||
size: number | string = "2rem",
|
||||
@@ -19,29 +86,10 @@ export function getFileTypeIcon(
|
||||
const name = (file?.name ?? "").toLowerCase();
|
||||
const mime = (file?.type ?? "").toLowerCase();
|
||||
const ext = detectFileExtension(name);
|
||||
|
||||
// JavaScript
|
||||
if (ext === "js" || mime.includes("javascript")) {
|
||||
return (
|
||||
<JavascriptIcon
|
||||
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (ext === "pdf" || mime === "application/pdf") {
|
||||
return (
|
||||
<PictureAsPdfIcon
|
||||
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback generic
|
||||
return (
|
||||
<InsertDriveFileIcon
|
||||
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
|
||||
<FileDocIcon
|
||||
variant={getFileDocVariant(ext, mime)}
|
||||
style={{ width: size, height: "auto" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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__label">
|
||||
{t("quickAccess.selectedFile", "Selected file")}
|
||||
{t("quickAccess.selectedFile", "File")}
|
||||
</div>
|
||||
<div className="quick-access-popout__row-title">
|
||||
{selectedFiles[0]?.name ||
|
||||
t("quickAccess.noFile", "No file selected")}
|
||||
t("quickAccess.noFile", "No file loaded")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--search-text-and-icon-color);
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -26,6 +27,8 @@
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--search-text-and-icon-color);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
@@ -62,6 +65,7 @@
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.2s ease;
|
||||
color: var(--search-text-and-icon-color);
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { ActionIcon, ScrollArea, Switch } from "@mantine/core";
|
||||
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
|
||||
import { useRef } from "react";
|
||||
import { ScrollArea, Switch } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
||||
@@ -9,10 +8,8 @@ import { ToolId } from "@app/types/toolId";
|
||||
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
|
||||
interface FullscreenToolSurfaceProps {
|
||||
searchQuery: string;
|
||||
@@ -28,7 +25,6 @@ interface FullscreenToolSurfaceProps {
|
||||
onSelect: (id: ToolId) => void;
|
||||
onToggleDescriptions: () => void;
|
||||
onExitFullscreenMode: () => void;
|
||||
toggleLabel: string;
|
||||
geometry: ToolPanelGeometry | null;
|
||||
}
|
||||
|
||||
@@ -42,47 +38,17 @@ const FullscreenToolSurface = ({
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
onToggleDescriptions,
|
||||
onExitFullscreenMode,
|
||||
toggleLabel,
|
||||
onExitFullscreenMode: _onExitFullscreenMode,
|
||||
geometry,
|
||||
}: FullscreenToolSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const isRTL =
|
||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||||
|
||||
// Enable focus trap when surface is active
|
||||
useFocusTrap(surfaceRef, !isExiting);
|
||||
useFocusTrap(surfaceRef, true);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
|
||||
const handleExit = () => {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
onExitFullscreenMode();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExiting(true);
|
||||
const el = surfaceRef.current;
|
||||
if (!el) {
|
||||
onExitFullscreenMode();
|
||||
return;
|
||||
}
|
||||
// Rely on CSS animation end rather than duplicating timing in JS
|
||||
el.addEventListener(
|
||||
"animationend",
|
||||
() => {
|
||||
onExitFullscreenMode();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
};
|
||||
|
||||
const style = geometry
|
||||
? {
|
||||
left: `${geometry.left}px`,
|
||||
@@ -103,10 +69,7 @@ const FullscreenToolSurface = ({
|
||||
)}
|
||||
data-tour="tool-panel"
|
||||
>
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className={`tool-panel__fullscreen-surface-inner ${isExiting ? "tool-panel__fullscreen-surface-inner--exiting" : ""}`}
|
||||
>
|
||||
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
|
||||
<header className="tool-panel__fullscreen-header">
|
||||
<div className="tool-panel__fullscreen-brand">
|
||||
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
|
||||
@@ -115,29 +78,6 @@ const FullscreenToolSurface = ({
|
||||
className="tool-panel__fullscreen-brand-text"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<Tooltip
|
||||
content={toggleLabel}
|
||||
position="bottom"
|
||||
arrow={true}
|
||||
openOnFocus={false}
|
||||
containerStyle={{ zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE }}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleExit}
|
||||
aria-label={toggleLabel}
|
||||
style={{ color: "var(--right-rail-icon)" }}
|
||||
>
|
||||
<DoubleArrowIcon
|
||||
fontSize="small"
|
||||
style={{ transform: isRTL ? undefined : "rotate(180deg)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="tool-panel__fullscreen-controls">
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/* ---- Viewer mode mini-toolbar (top of ToolPanel) ---- */
|
||||
.tool-panel-viewer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background-color: var(--bg-toolbar);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* CSS Custom Properties for Fullscreen Mode */
|
||||
.tool-panel__fullscreen-surface-inner {
|
||||
--fullscreen-bg-surface-1: color-mix(
|
||||
@@ -109,16 +120,8 @@
|
||||
var(--text-primary) 20%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
--fullscreen-text-icon: color-mix(
|
||||
in srgb,
|
||||
var(--text-primary) 90%,
|
||||
var(--text-muted)
|
||||
);
|
||||
--fullscreen-text-icon-compact: color-mix(
|
||||
in srgb,
|
||||
var(--text-primary) 88%,
|
||||
var(--text-muted)
|
||||
);
|
||||
--fullscreen-text-icon: var(--text-primary);
|
||||
--fullscreen-text-icon-compact: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
@@ -128,6 +131,49 @@
|
||||
max-width 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-top: 10px;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary) !important;
|
||||
border-color: var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn svg {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn:hover {
|
||||
color: var(--text-primary) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-search-btn {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-search-btn svg {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-search-btn:hover {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.tool-panel__back-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel--fullscreen-active {
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -136,7 +182,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
height: 48px;
|
||||
padding: 0 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tool-panel__search-row .search-input-container {
|
||||
@@ -469,6 +517,8 @@
|
||||
|
||||
.tool-panel__fullscreen-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-description {
|
||||
@@ -604,7 +654,7 @@
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in {
|
||||
from {
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -619,14 +669,14 @@
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in-rtl {
|
||||
from {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -641,7 +691,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
@@ -8,15 +8,18 @@ import ToolRenderer from "@app/components/tools/ToolRenderer";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
||||
import { ActionIcon, ScrollArea } from "@mantine/core";
|
||||
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import { useRightRail } from "@app/contexts/RightRailContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
|
||||
// No props needed - component uses context
|
||||
@@ -25,7 +28,7 @@ export default function ToolPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
||||
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
@@ -37,14 +40,18 @@ export default function ToolPanel() {
|
||||
setSearchQuery,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
setPreviewFile,
|
||||
toolPanelMode,
|
||||
setToolPanelMode,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setSidebarsVisible,
|
||||
sidebarsVisible,
|
||||
readerMode,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { setAllRightRailButtonsDisabled } = useRightRail();
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const isFullscreenMode = toolPanelMode === "fullscreen";
|
||||
@@ -54,41 +61,54 @@ export default function ToolPanel() {
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
toolPickerVisible;
|
||||
const isRTL =
|
||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||||
|
||||
// Disable right rail buttons when fullscreen mode is active
|
||||
// Disable workbench bar buttons when fullscreen mode is active
|
||||
useEffect(() => {
|
||||
setAllRightRailButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllRightRailButtonsDisabled]);
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
rightRailRef,
|
||||
});
|
||||
|
||||
const toggleLabel = isFullscreenMode
|
||||
? t("toolPanel.toggle.sidebar", "Switch to sidebar mode")
|
||||
: t("toolPanel.toggle.fullscreen", "Switch to fullscreen mode");
|
||||
|
||||
const handleModeToggle = () => {
|
||||
const nextMode = isFullscreenMode ? "sidebar" : "fullscreen";
|
||||
setToolPanelMode(nextMode);
|
||||
|
||||
if (nextMode === "fullscreen" && leftPanelView !== "toolPicker") {
|
||||
setLeftPanelView("toolPicker");
|
||||
}
|
||||
const handleExpand = () => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setLeftPanelView("hidden");
|
||||
};
|
||||
|
||||
const [focusSearch, setFocusSearch] = useState(false);
|
||||
const focusSearchOnNextOpen = useRef(false);
|
||||
|
||||
const handleExpandAndSearch = () => {
|
||||
focusSearchOnNextOpen.current = true;
|
||||
handleExpand();
|
||||
};
|
||||
|
||||
// Once the panel becomes visible, consume the focus-search request
|
||||
useEffect(() => {
|
||||
if (isPanelVisible && focusSearchOnNextOpen.current) {
|
||||
focusSearchOnNextOpen.current = false;
|
||||
setFocusSearch(true);
|
||||
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
|
||||
const id = setTimeout(() => setFocusSearch(false), 100);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [isPanelVisible]);
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) {
|
||||
return "100%";
|
||||
}
|
||||
|
||||
if (!isPanelVisible) {
|
||||
return "0";
|
||||
return "3.5rem";
|
||||
}
|
||||
|
||||
return "18.5rem";
|
||||
@@ -109,7 +129,7 @@ export default function ToolPanel() {
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
@@ -117,21 +137,52 @@ export default function ToolPanel() {
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
{!fullscreenExpanded && (
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
className="tool-panel__collapsed-search-btn"
|
||||
onClick={handleExpandAndSearch}
|
||||
aria-label={t("toolPanel.search", "Search tools")}
|
||||
style={{ marginTop: "8px" }}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
<div
|
||||
style={{
|
||||
opacity: isMobile || isPanelVisible ? 1 : 0,
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
<div
|
||||
className="tool-panel__search-row"
|
||||
style={{
|
||||
backgroundColor: "var(--tool-panel-search-bg)",
|
||||
borderBottom: "1px solid var(--tool-panel-search-border-bottom)",
|
||||
backgroundColor: "transparent",
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
@@ -139,29 +190,19 @@ export default function ToolPanel() {
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={focusSearch}
|
||||
/>
|
||||
{!isMobile && leftPanelView === "toolPicker" && (
|
||||
<Tooltip
|
||||
content={toggleLabel}
|
||||
position="bottom"
|
||||
arrow={true}
|
||||
openOnFocus={false}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
style={{ color: "var(--right-rail-icon)" }}
|
||||
onClick={handleModeToggle}
|
||||
aria-label={toggleLabel}
|
||||
className="tool-panel__mode-toggle"
|
||||
>
|
||||
<DoubleArrowIcon
|
||||
fontSize="small"
|
||||
style={{ transform: isRTL ? "scaleX(-1)" : undefined }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
@@ -185,6 +226,26 @@ export default function ToolPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
fullWidth
|
||||
radius={0}
|
||||
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={handleBackToTools}
|
||||
aria-label={t("toolPanel.backToTools", "Back to tools")}
|
||||
styles={{ root: { justifyContent: "flex-start" } }}
|
||||
>
|
||||
{t("toolPanel.backToTools", "Back to tools")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
@@ -224,7 +285,6 @@ export default function ToolPanel() {
|
||||
)
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
toggleLabel={toggleLabel}
|
||||
geometry={fullscreenGeometry}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { 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;
|
||||
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
||||
const headerTextStyle: React.CSSProperties = {
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.5rem 0 0.25rem 0.5rem",
|
||||
textTransform: "none",
|
||||
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
|
||||
opacity: 0.7,
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 600,
|
||||
padding: "1rem 0 0.35rem 0.5rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--text-muted)",
|
||||
};
|
||||
const toTitleCase = (s: string) =>
|
||||
s.replace(
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
|
||||
import { EXECUTION_STATUS } from "@app/constants/automation";
|
||||
@@ -29,7 +29,7 @@ export default function AutomationRun({
|
||||
automateOperation,
|
||||
}: AutomationRunProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
const { regularTools } = useToolRegistry();
|
||||
const toolRegistry = regularTools;
|
||||
const cleanup = useResourceCleanup();
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
} from "@app/components/tools/compare/compare";
|
||||
import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
|
||||
const COMPARE_PANE_TITLE_MAX_LEN = 48;
|
||||
|
||||
// utilities moved to compare.ts
|
||||
|
||||
@@ -107,9 +110,14 @@ const CompareDocumentPane = ({
|
||||
return (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{title}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text
|
||||
fw={600}
|
||||
size="lg"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
title={title || undefined}
|
||||
>
|
||||
{title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title}
|
||||
</Text>
|
||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
import { CompareResultData, CompareWorkbenchData } from "@app/types/compare";
|
||||
import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView";
|
||||
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 { useComparePagePreviews } from "@app/components/tools/compare/hooks/useComparePagePreviews";
|
||||
import { useComparePanZoom } from "@app/components/tools/compare/hooks/useComparePanZoom";
|
||||
import { useCompareHighlights } from "@app/components/tools/compare/hooks/useCompareHighlights";
|
||||
import { useCompareChangeNavigation } from "@app/components/tools/compare/hooks/useCompareChangeNavigation";
|
||||
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 {
|
||||
alert,
|
||||
updateToast,
|
||||
@@ -209,7 +209,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
||||
})
|
||||
);
|
||||
|
||||
const rightRailButtons = useCompareRightRailButtons({
|
||||
const workbenchBarButtons = useCompareWorkbenchBarButtons({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
@@ -230,7 +230,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
||||
comparisonScrollRef,
|
||||
});
|
||||
|
||||
useRightRailButtons(rightRailButtons);
|
||||
useWorkbenchBarButtons(workbenchBarButtons);
|
||||
|
||||
// Rendering progress toast for very large PDFs
|
||||
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
padding-top: 3rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
||||
/* Allow the custom workbench to shrink within flex parents */
|
||||
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 */
|
||||
.compare-pixel-workbench {
|
||||
padding: 16px 24px 32px;
|
||||
|
||||
+7
-7
@@ -4,12 +4,12 @@ import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
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";
|
||||
|
||||
type Pane = "base" | "comparison";
|
||||
|
||||
export interface UseCompareRightRailButtonsOptions {
|
||||
export interface UseCompareWorkbenchBarButtonsOptions {
|
||||
layout: "side-by-side" | "stacked";
|
||||
toggleLayout: () => void;
|
||||
isPanMode: boolean;
|
||||
@@ -30,7 +30,7 @@ export interface UseCompareRightRailButtonsOptions {
|
||||
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const useCompareRightRailButtons = ({
|
||||
export const useCompareWorkbenchBarButtons = ({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
@@ -49,11 +49,11 @@ export const useCompareRightRailButtons = ({
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||
}: UseCompareWorkbenchBarButtonsOptions): WorkbenchBarButtonWithAction[] => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return useMemo<RightRailButtonWithAction[]>(
|
||||
return useMemo<WorkbenchBarButtonWithAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: "compare-toggle-layout",
|
||||
@@ -212,6 +212,6 @@ export const useCompareRightRailButtons = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type UseCompareRightRailButtonsReturn = ReturnType<
|
||||
typeof useCompareRightRailButtons
|
||||
export type UseCompareWorkbenchBarButtonsReturn = ReturnType<
|
||||
typeof useCompareWorkbenchBarButtons
|
||||
>;
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters";
|
||||
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector";
|
||||
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
|
||||
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
|
||||
@@ -34,17 +34,17 @@ const CONTAINER_SIZE = 250; // Fit within actual pane width
|
||||
|
||||
const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles, selectedFileStubs } = useSelectedFiles();
|
||||
const { files, fileStubs } = useAllFiles();
|
||||
|
||||
// Get the first selected file for preview
|
||||
// Get the first file for preview
|
||||
const selectedStub = useMemo(() => {
|
||||
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
|
||||
}, [selectedFileStubs]);
|
||||
return fileStubs.length > 0 ? fileStubs[0] : null;
|
||||
}, [fileStubs]);
|
||||
|
||||
// Get the first selected file for PDF processing
|
||||
// Get the first file for PDF processing
|
||||
const selectedFile = useMemo(() => {
|
||||
return selectedFiles.length > 0 ? selectedFiles[0] : null;
|
||||
}, [selectedFiles]);
|
||||
return files.length > 0 ? files[0] : null;
|
||||
}, [files]);
|
||||
|
||||
// Get thumbnail for the selected file
|
||||
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
||||
|
||||
@@ -76,9 +76,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({
|
||||
) : null}
|
||||
<span className="tool-panel__fullscreen-list-body">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||
{tool.versionStatus === "alpha" && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{t("toolPanel.alpha", "Alpha")}
|
||||
|
||||
@@ -78,9 +78,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({
|
||||
) : null}
|
||||
<span className="tool-panel__fullscreen-body">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
|
||||
{tool.versionStatus === "alpha" && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{/* we can add more translations for different badges in future, like beta, etc. */}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters";
|
||||
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
|
||||
interface RotateSettingsProps {
|
||||
@@ -17,12 +17,12 @@ const RotateSettings = ({
|
||||
disabled = false,
|
||||
}: RotateSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFileStubs } = useSelectedFiles();
|
||||
const { fileStubs } = useAllFiles();
|
||||
|
||||
// Get the first selected file for preview
|
||||
// Get the first file for preview
|
||||
const selectedStub = useMemo(() => {
|
||||
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
|
||||
}, [selectedFileStubs]);
|
||||
return fileStubs.length > 0 ? fileStubs[0] : null;
|
||||
}, [fileStubs]);
|
||||
|
||||
// Get thumbnail for the selected file
|
||||
const [thumbnail, setThumbnail] = useState<string | null>(null);
|
||||
|
||||
@@ -59,17 +59,12 @@ const FileStatusIndicator = ({
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (minFiles === undefined || minFiles === 1) {
|
||||
return t(
|
||||
"files.selectFromWorkbench",
|
||||
"Select files from the workbench or ",
|
||||
);
|
||||
return t("files.selectFromWorkbench", "Add files to the workbench or ");
|
||||
} else {
|
||||
return t(
|
||||
"files.selectMultipleFromWorkbench",
|
||||
"Select at least {{count}} files from the workbench or ",
|
||||
{
|
||||
count: minFiles,
|
||||
},
|
||||
"Add at least {{count}} files to the workbench or ",
|
||||
{ count: minFiles },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -171,14 +166,12 @@ const FileStatusIndicator = ({
|
||||
✓{" "}
|
||||
{selectedFiles.length === 1 ? (
|
||||
<PrivateContent>
|
||||
{t("fileSelected", "Selected: {{filename}}", {
|
||||
{t("fileSelected", "{{filename}}", {
|
||||
filename: selectedFiles[0]?.name,
|
||||
})}
|
||||
</PrivateContent>
|
||||
) : (
|
||||
t("filesSelected", "{{count}} files selected", {
|
||||
count: selectedFiles.length,
|
||||
})
|
||||
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,10 @@ const OperationButton = ({
|
||||
"tool.endpointUnavailable",
|
||||
"This tool is unavailable on your server.",
|
||||
),
|
||||
filesLoading: t(
|
||||
"tool.filesLoading",
|
||||
"Files are still loading, please wait.",
|
||||
),
|
||||
noFiles: t("tool.noFiles", "Add a file to get started."),
|
||||
invalidParams: t("tool.invalidParams", "Fill in the required settings."),
|
||||
viewerMode: t(
|
||||
|
||||
@@ -120,6 +120,7 @@ function ReviewStepContent<TParams = unknown>({
|
||||
|
||||
{onUndo && (
|
||||
<Tooltip
|
||||
position="left"
|
||||
content={t(
|
||||
"undoOperationTooltip",
|
||||
"Click to undo the last operation and restore the original files",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { Text, Box, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OperationButton, {
|
||||
OperationButtonProps,
|
||||
@@ -18,8 +18,7 @@ export interface ScopedOperationButtonProps extends OperationButtonProps {
|
||||
*
|
||||
* - Viewer mode (multiple files loaded): appends "(this file)" to button text and
|
||||
* shows a note naming the exact file that will be processed.
|
||||
* - File-editor mode with N>1 selected files: appends "(N files)" to button text.
|
||||
* - File-editor mode with 0 selected files: shows a hint to select files.
|
||||
* - N>1 files loaded: appends "(N files)" to button text.
|
||||
* - All other cases: no change to button text or layout.
|
||||
*/
|
||||
export function ScopedOperationButton({
|
||||
@@ -30,7 +29,14 @@ export function ScopedOperationButton({
|
||||
const { t } = useTranslation();
|
||||
const { workbench } = useNavigationState();
|
||||
const { activeFileIndex } = useViewer();
|
||||
const { files: allFiles } = useAllFiles();
|
||||
const { files: allFiles, fileIds } = useAllFiles();
|
||||
|
||||
// Disable until all files are hydrated — running early would silently skip unloaded files.
|
||||
const isFilesHydrating = fileIds.length > allFiles.length;
|
||||
const effectiveDisabledReason =
|
||||
isFilesHydrating && props.disabledReason !== "endpointUnavailable"
|
||||
? "filesLoading"
|
||||
: props.disabledReason;
|
||||
|
||||
const isViewerMode = workbench === "viewer";
|
||||
const isFileEditorMode = workbench === "fileEditor";
|
||||
@@ -56,6 +62,11 @@ export function ScopedOperationButton({
|
||||
? allFiles[activeFileIndex]?.name
|
||||
: null;
|
||||
|
||||
const pendingCount = fileIds.length - allFiles.length;
|
||||
const isBulkLoading = pendingCount > 1;
|
||||
const loadProgress = isBulkLoading
|
||||
? (allFiles.length / fileIds.length) * 100
|
||||
: 0;
|
||||
const showSelectFilesHint =
|
||||
!disableScopeHints &&
|
||||
isFileEditorMode &&
|
||||
@@ -64,7 +75,54 @@ export function ScopedOperationButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<OperationButton {...props} submitText={scopedText} />
|
||||
{isFilesHydrating ? (
|
||||
<Box mx="md" mt="md">
|
||||
<Button
|
||||
fullWidth
|
||||
disabled
|
||||
loading={!isBulkLoading}
|
||||
variant={props.variant ?? "filled"}
|
||||
color={props.color ?? "blue"}
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
minHeight: "2.5rem",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{isBulkLoading && (
|
||||
<>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${loadProgress}%`,
|
||||
backgroundColor: "rgba(255,255,255,0.15)",
|
||||
transition: "width 0.3s ease",
|
||||
borderRadius: "inherit",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" style={{ position: "relative" }}>
|
||||
{t(
|
||||
"tool.filesLoadingProgress",
|
||||
"{{loaded}} / {{total}} files loading...",
|
||||
{
|
||||
loaded: allFiles.length,
|
||||
total: fileIds.length,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<OperationButton
|
||||
{...props}
|
||||
submitText={scopedText}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
/>
|
||||
)}
|
||||
{viewerFileName && (
|
||||
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||
{t("tool.singleFileScope", "Only applying to: {{fileName}}", {
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
border: 1px solid var(--mantine-color-gray-4);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--right-rail-bg);
|
||||
background: var(--bg-file-manager);
|
||||
}
|
||||
|
||||
.showjs-toolbar {
|
||||
|
||||
@@ -201,13 +201,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
handleUnlessSpecialClick(e, () => handleClick(id));
|
||||
};
|
||||
|
||||
const selectedStyles = isSelected
|
||||
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
|
||||
: {};
|
||||
|
||||
const buttonElement = navProps ? (
|
||||
// For internal tools with URLs, render Button as an anchor for proper link behavior
|
||||
<Button
|
||||
component="a"
|
||||
href={navProps.href}
|
||||
onClick={navProps.onClick}
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
@@ -219,6 +223,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -233,7 +238,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleExternalClick}
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
@@ -245,6 +250,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -254,7 +260,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
) : (
|
||||
// For unavailable tools, use regular button
|
||||
<Button
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
variant="subtle"
|
||||
onClick={() => handleClick(id)}
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -269,6 +275,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -292,7 +299,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
{star}
|
||||
<Tooltip
|
||||
content={tooltipContent}
|
||||
position="right"
|
||||
position="left"
|
||||
arrow={true}
|
||||
delay={500}
|
||||
>
|
||||
|
||||
@@ -57,13 +57,16 @@
|
||||
|
||||
/* Compact tool buttons */
|
||||
.tool-button {
|
||||
font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */
|
||||
padding-top: 0.375rem;
|
||||
padding-bottom: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-button .mantine-Button-label {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tool-button-icon {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ToolSearchProps {
|
||||
selectedToolKey?: string | null;
|
||||
placeholder?: string;
|
||||
hideIcon?: boolean;
|
||||
iconOverride?: React.ReactNode;
|
||||
onFocus?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ const ToolSearch = ({
|
||||
selectedToolKey,
|
||||
placeholder,
|
||||
hideIcon = false,
|
||||
iconOverride,
|
||||
onFocus,
|
||||
autoFocus = false,
|
||||
}: ToolSearchProps) => {
|
||||
@@ -96,10 +98,12 @@ const ToolSearch = ({
|
||||
placeholder || t("toolPicker.searchPlaceholder", "Search tools...")
|
||||
}
|
||||
icon={
|
||||
hideIcon ? undefined : (
|
||||
<LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />
|
||||
)
|
||||
iconOverride ??
|
||||
(hideIcon ? undefined : (
|
||||
<LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />
|
||||
))
|
||||
}
|
||||
iconClickable={!!iconOverride}
|
||||
autoComplete="off"
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
|
||||
@@ -538,7 +538,7 @@ export function CommentsSidebar({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
backgroundColor: "var(--right-rail-bg)",
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
borderLeft: "1px solid var(--border-subtle)",
|
||||
zIndex: 998,
|
||||
display: "flex",
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
@@ -27,7 +28,7 @@ import { useRedaction } from "@app/contexts/RedactionContext";
|
||||
import type { RedactionPendingTrackerAPI } from "@app/components/viewer/RedactionPendingTracker";
|
||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||
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 {
|
||||
RulerOverlay,
|
||||
@@ -138,8 +139,6 @@ export interface EmbedPdfViewerProps {
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
const EmbedPdfViewerContent = ({
|
||||
@@ -147,9 +146,8 @@ const EmbedPdfViewerContent = ({
|
||||
setSidebarsVisible: _setSidebarsVisible,
|
||||
onClose,
|
||||
previewFile,
|
||||
activeFileIndex: externalActiveFileIndex,
|
||||
setActiveFileIndex: externalSetActiveFileIndex,
|
||||
}: EmbedPdfViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
@@ -178,6 +176,9 @@ const EmbedPdfViewerContent = ({
|
||||
applyChanges: viewerApplyChanges,
|
||||
pdfRenderMode,
|
||||
cyclePdfRenderMode,
|
||||
setActiveFileIndex: _setActiveFileIndex,
|
||||
activeFileId,
|
||||
setActiveFileId,
|
||||
} = useViewer();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
@@ -327,63 +328,25 @@ const EmbedPdfViewerContent = ({
|
||||
isInAnnotationTool && isPlacementMode && signatureConfig,
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
|
||||
const setActiveFileIndex =
|
||||
externalSetActiveFileIndex ?? setInternalActiveFileIndex;
|
||||
|
||||
// activeFileId (from ViewerContext) is the stable source of truth.
|
||||
// We derive activeFileIndex from it so reorders after tool operations don't lose the viewed file.
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
|
||||
// Stable string key representing the current file list order.
|
||||
// Using a joined ID string avoids depending on the activeFiles array reference,
|
||||
// which is a new object every render and would cause an infinite effect loop.
|
||||
const fileIdsKey = activeFiles.map((f) => f.fileId).join(",");
|
||||
|
||||
// When the file list actually changes, re-derive activeFileIndex from the stable activeFileId.
|
||||
useEffect(() => {
|
||||
if (!activeFileId || activeFiles.length === 0) return;
|
||||
const newIndex = activeFiles.findIndex((f) => f.fileId === activeFileId);
|
||||
if (newIndex !== -1 && newIndex !== activeFileIndex) {
|
||||
setActiveFileIndex(newIndex);
|
||||
}
|
||||
}, [fileIdsKey, activeFileId]); // stable primitives — no infinite loop
|
||||
|
||||
// When the user manually switches file tabs, keep activeFileId in sync.
|
||||
// Skips the initial mount to avoid overwriting an activeFileId set by handleViewFile.
|
||||
const activeFileIndexMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!activeFileIndexMountedRef.current) {
|
||||
activeFileIndexMountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const fileId = activeFilesRef.current[activeFileIndex]?.fileId;
|
||||
if (fileId && fileId !== activeFileId) {
|
||||
setActiveFileId(fileId);
|
||||
}
|
||||
}, [activeFileIndex]);
|
||||
|
||||
// Reset active tab if it's out of bounds (safety net)
|
||||
useEffect(() => {
|
||||
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
|
||||
setActiveFileIndex(0);
|
||||
}
|
||||
}, [activeFiles.length, activeFileIndex]);
|
||||
|
||||
// Determine which file to display
|
||||
// Determine which file to display — use activeFileId (stable) not activeFileIndex (shifts on removal)
|
||||
const currentFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
return previewFile;
|
||||
} else if (activeFiles.length > 0) {
|
||||
return activeFiles[activeFileIndex] || activeFiles[0];
|
||||
const byId = activeFileId
|
||||
? activeFiles.find(
|
||||
(f) => isStirlingFile(f) && f.fileId === activeFileId,
|
||||
)
|
||||
: null;
|
||||
return byId || activeFiles[0];
|
||||
}
|
||||
return null;
|
||||
}, [previewFile, activeFiles, activeFileIndex]);
|
||||
}, [previewFile, activeFiles, activeFileId]);
|
||||
|
||||
// Get file with URL for rendering
|
||||
const fileWithUrl = useFileWithUrl(currentFile);
|
||||
// Stable id — avoids blob URL churn when FileContext recreates file objects each render.
|
||||
const currentFileStableId =
|
||||
currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null;
|
||||
const fileWithUrl = useFileWithUrl(currentFile, currentFileStableId);
|
||||
|
||||
// Determine the effective file to display
|
||||
const effectiveFile = React.useMemo(() => {
|
||||
@@ -703,7 +666,7 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
// Only consume the current file, not all active files
|
||||
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||
const currentFileId = currentFileStableId;
|
||||
if (!currentFileId) throw new Error("Current file ID not found");
|
||||
|
||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||
@@ -739,7 +702,6 @@ const EmbedPdfViewerContent = ({
|
||||
}, [
|
||||
currentFile,
|
||||
activeFiles,
|
||||
activeFileIndex,
|
||||
exportActions,
|
||||
actions,
|
||||
selectors,
|
||||
@@ -773,7 +735,7 @@ const EmbedPdfViewerContent = ({
|
||||
});
|
||||
|
||||
// Get current file info for creating the updated version
|
||||
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||
const currentFileId = currentFileStableId;
|
||||
if (!currentFileId) throw new Error("Current file ID not found");
|
||||
|
||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||
@@ -811,7 +773,6 @@ const EmbedPdfViewerContent = ({
|
||||
[
|
||||
currentFile,
|
||||
activeFiles,
|
||||
activeFileIndex,
|
||||
actions,
|
||||
selectors,
|
||||
activeFileIds.length,
|
||||
@@ -847,7 +808,7 @@ const EmbedPdfViewerContent = ({
|
||||
type: "application/pdf",
|
||||
});
|
||||
|
||||
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||
const currentFileId = currentFileStableId;
|
||||
if (!currentFileId) throw new Error("Current file ID not found");
|
||||
|
||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||
@@ -877,7 +838,6 @@ const EmbedPdfViewerContent = ({
|
||||
[
|
||||
currentFile,
|
||||
activeFiles,
|
||||
activeFileIndex,
|
||||
actions,
|
||||
selectors,
|
||||
activeFileIds.length,
|
||||
@@ -916,7 +876,7 @@ const EmbedPdfViewerContent = ({
|
||||
const file = new File([blob], filename, { type: "application/pdf" });
|
||||
|
||||
// Create StirlingFiles and stubs for version history
|
||||
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||
const currentFileId = currentFileStableId;
|
||||
if (!currentFileId) throw new Error("Current file ID not found");
|
||||
|
||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||
@@ -949,7 +909,6 @@ const EmbedPdfViewerContent = ({
|
||||
redactionsApplied,
|
||||
currentFile,
|
||||
activeFiles,
|
||||
activeFileIndex,
|
||||
activeFileIds.length,
|
||||
exportActions,
|
||||
actions,
|
||||
@@ -1123,8 +1082,8 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [effectiveFile]);
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
||||
// Register workbench bar buttons for the viewer
|
||||
useViewerWorkbenchBarButtons(isRulerActive, setIsRulerActive);
|
||||
|
||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||
// 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.
|
||||
}
|
||||
|
||||
if (currentFile && (fileChanged || providerChanged)) {
|
||||
if (
|
||||
currentFile &&
|
||||
!isCurrentFileEncrypted &&
|
||||
(fileChanged || providerChanged)
|
||||
) {
|
||||
console.log("[FormFill] Fetching form fields for:", currentFileId);
|
||||
fetchFormFields(currentFile, currentFileId ?? undefined);
|
||||
}
|
||||
@@ -1219,7 +1182,12 @@ const EmbedPdfViewerContent = ({
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Stack align="center" gap="md">
|
||||
<LockIcon style={{ fontSize: 48, opacity: 0.5 }} />
|
||||
<Text fw={500}>This PDF is password-protected</Text>
|
||||
<Text fw={500}>
|
||||
{t(
|
||||
"encryptedPdfUnlock.viewerLocked",
|
||||
"This PDF is password-protected",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => {
|
||||
@@ -1228,7 +1196,7 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Unlock
|
||||
{t("encryptedPdfUnlock.viewerUnlock", "Unlock")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -1329,7 +1297,7 @@ const EmbedPdfViewerContent = ({
|
||||
<ThumbnailSidebar
|
||||
visible={isThumbnailSidebarVisible}
|
||||
onToggle={toggleThumbnailSidebar}
|
||||
activeFileIndex={activeFileIndex}
|
||||
activeFileId={activeFileId}
|
||||
/>
|
||||
<BookmarkSidebar
|
||||
visible={isBookmarkSidebarVisible}
|
||||
|
||||
@@ -162,7 +162,10 @@ export function LocalEmbedPDF({
|
||||
});
|
||||
}, [config?.enableLogin]);
|
||||
|
||||
// Convert File to URL if needed
|
||||
// Stable key — avoids recreating the blob URL (and crashing ViewportPlugin) when
|
||||
// FileContext produces new File object references for the same file content.
|
||||
const fileStableKey =
|
||||
fileId ?? (file ? `${(file as File).name}-${file.size}` : null);
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
@@ -171,7 +174,17 @@ export function LocalEmbedPDF({
|
||||
} else if (url) {
|
||||
setPdfUrl(url);
|
||||
}
|
||||
}, [file, url]);
|
||||
// When file is present, use the stable key to avoid blob URL churn from FileContext
|
||||
// re-renders. When only url is provided, depend on url directly so changes are picked up.
|
||||
}, [file ? fileStableKey : url]);
|
||||
|
||||
// Keyed by fileStableKey to avoid recomputing on every FileContext re-render.
|
||||
const exportFileName = useMemo(() => {
|
||||
if (fileName) return fileName;
|
||||
if (file && "name" in file) return (file as File).name;
|
||||
if (url) return url.split("/").pop()?.split("?")[0] || "document.pdf";
|
||||
return "document.pdf";
|
||||
}, [fileStableKey, fileName, url]);
|
||||
|
||||
// Create plugins configuration
|
||||
const plugins = useMemo(() => {
|
||||
@@ -183,17 +196,6 @@ export function LocalEmbedPDF({
|
||||
);
|
||||
const viewportGap = rootFontSize * 3.5;
|
||||
|
||||
// Determine export filename - use provided fileName, or extract from file/url
|
||||
let exportFileName = "document.pdf";
|
||||
if (fileName) {
|
||||
exportFileName = fileName;
|
||||
} else if (file && "name" in file) {
|
||||
exportFileName = file.name;
|
||||
} else if (url) {
|
||||
const urlPath = url.split("/").pop() || "document.pdf";
|
||||
exportFileName = urlPath.split("?")[0]; // Remove query params
|
||||
}
|
||||
|
||||
return [
|
||||
createPluginRegistration(DocumentManagerPluginPackage, {
|
||||
initialDocuments: [
|
||||
@@ -290,7 +292,7 @@ export function LocalEmbedPDF({
|
||||
// Register print plugin for printing PDFs
|
||||
createPluginRegistration(PrintPluginPackage),
|
||||
];
|
||||
}, [pdfUrl, enableAnnotations, fileName, file, url]);
|
||||
}, [pdfUrl, enableAnnotations, exportFileName]);
|
||||
|
||||
// Initialize the engine with the React hook - use local WASM for offline support
|
||||
const { engine, isLoading, error } = usePdfiumEngine({
|
||||
|
||||
@@ -4,6 +4,7 @@ import ArticleIcon from "@mui/icons-material/Article";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
detectFileExtension,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
import { CONVERSION_MATRIX } from "@app/constants/convertConstants";
|
||||
|
||||
import { NonPdfBanner } from "@app/components/viewer/nonpdf/NonPdfBanner";
|
||||
import { getFileTypeMeta } from "@app/components/viewer/nonpdf/types";
|
||||
import { ImageViewer } from "@app/components/viewer/nonpdf/ImageViewer";
|
||||
import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer";
|
||||
import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer";
|
||||
@@ -24,8 +24,6 @@ export interface ViewerProps {
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface NonPdfViewerProps extends ViewerProps {
|
||||
@@ -34,10 +32,6 @@ export interface NonPdfViewerProps extends ViewerProps {
|
||||
|
||||
export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
||||
const fileType = useMemo(() => detectNonPdfFileType(file), [file]);
|
||||
const meta = useMemo(
|
||||
() => getFileTypeMeta(fileType, file.name),
|
||||
[fileType, file.name],
|
||||
);
|
||||
|
||||
const { handleToolSelect, toolAvailability } = useToolWorkflow();
|
||||
|
||||
@@ -112,7 +106,6 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
||||
}}
|
||||
>
|
||||
<NonPdfBanner
|
||||
meta={meta}
|
||||
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
|
||||
/>
|
||||
<Box
|
||||
@@ -134,7 +127,7 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
||||
export function NonPdfViewerWrapper(props: ViewerProps) {
|
||||
const { selectors } = useFileState();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIndex = props.activeFileIndex ?? 0;
|
||||
const { activeFileIndex } = useViewer();
|
||||
|
||||
const file =
|
||||
props.previewFile ?? activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
|
||||
@@ -143,7 +136,7 @@ export function NonPdfViewerWrapper(props: ViewerProps) {
|
||||
return (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Text c="dimmed" size="sm">
|
||||
No file selected
|
||||
No file loaded
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Paper, Group, NumberInput } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Paper,
|
||||
Group,
|
||||
NumberInput,
|
||||
Slider,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
@@ -12,6 +19,8 @@ import ViewWeekIcon from "@mui/icons-material/ViewWeek";
|
||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||
import WbSunnyIcon from "@mui/icons-material/WbSunny";
|
||||
import WbTwilightIcon from "@mui/icons-material/WbTwilight";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||
|
||||
interface PdfViewerToolbarProps {
|
||||
// Page navigation props (placeholders for now)
|
||||
@@ -291,31 +300,48 @@ export function PdfViewerToolbar({
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||
<Button
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
radius="md"
|
||||
onClick={handleZoomOut}
|
||||
style={{ minWidth: "2rem", padding: 0 }}
|
||||
title={t("viewer.zoomOut", "Zoom out")}
|
||||
aria-label={t("viewer.zoomOut", "Zoom out")}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<Slider
|
||||
value={Math.min(Math.max(displayZoomPercent, 20), 500)}
|
||||
min={20}
|
||||
max={500}
|
||||
step={5}
|
||||
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: { width: "6rem" },
|
||||
thumb: { width: 14, height: 14 },
|
||||
track: { height: 3 },
|
||||
}}
|
||||
label={null}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
radius="md"
|
||||
onClick={handleZoomIn}
|
||||
aria-label={t("viewer.zoomIn", "Zoom in")}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<span
|
||||
style={{
|
||||
minWidth: "2.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<span style={{ minWidth: "2.5rem", textAlign: "center" }}>
|
||||
{displayZoomPercent}%
|
||||
</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleZoomIn}
|
||||
style={{ minWidth: "2rem", padding: 0 }}
|
||||
title={t("viewer.zoomIn", "Zoom in")}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,13 @@ import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
interface ThumbnailSidebarProps {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
activeFileIndex?: number;
|
||||
activeFileId?: string | null;
|
||||
}
|
||||
|
||||
export function ThumbnailSidebar({
|
||||
visible,
|
||||
onToggle: _onToggle,
|
||||
activeFileIndex,
|
||||
activeFileId,
|
||||
}: ThumbnailSidebarProps) {
|
||||
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
|
||||
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
|
||||
@@ -29,7 +29,7 @@ export function ThumbnailSidebar({
|
||||
}
|
||||
});
|
||||
setThumbnails({});
|
||||
}, [activeFileIndex]);
|
||||
}, [activeFileId]);
|
||||
|
||||
// Keep a ref to thumbnails for cleanup on unmount
|
||||
const thumbnailsRef = useRef(thumbnails);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type ViewerProps,
|
||||
} from "@app/components/viewer/NonPdfViewer";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { isStirlingFile } from "@app/types/fileContext";
|
||||
import { isPdfFile } from "@app/utils/fileUtils";
|
||||
|
||||
export type { ViewerProps };
|
||||
@@ -12,13 +14,16 @@ export type { ViewerProps };
|
||||
const Viewer = (props: ViewerProps) => {
|
||||
const { selectors } = useFileState();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIndex = props.activeFileIndex ?? 0;
|
||||
const { activeFileId } = useViewer();
|
||||
|
||||
// Determine the active file — previewFile takes priority
|
||||
// Determine the active file — previewFile takes priority, then look up by stable ID
|
||||
const activeFile = useMemo(() => {
|
||||
if (props.previewFile) return props.previewFile;
|
||||
return activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
|
||||
}, [props.previewFile, activeFiles, activeFileIndex]);
|
||||
const byId = activeFileId
|
||||
? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId)
|
||||
: null;
|
||||
return byId ?? activeFiles[0] ?? null;
|
||||
}, [props.previewFile, activeFiles, activeFileId]);
|
||||
|
||||
// Route to the appropriate viewer based on file type
|
||||
if (activeFile && !isPdfFile(activeFile)) {
|
||||
|
||||
+13
-28
@@ -12,9 +12,7 @@ import {
|
||||
useNavigationGuard,
|
||||
useNavigationActions,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
|
||||
import { useRedactionMode, useRedaction } from "@app/contexts/RedactionContext";
|
||||
import {
|
||||
defaultParameters,
|
||||
@@ -32,10 +30,7 @@ export default function ViewerAnnotationControls({
|
||||
disabled = false,
|
||||
}: ViewerAnnotationControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
|
||||
const { position: tooltipPosition, offset: tooltipOffset } =
|
||||
useRightRailTooltipSide(sidebarRefs);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
@@ -129,31 +124,25 @@ export default function ViewerAnnotationControls({
|
||||
// Handle redaction mode toggle
|
||||
const handleRedactionToggle = async () => {
|
||||
if (isRedactMode) {
|
||||
// Exit redaction mode
|
||||
exitRedactionMode();
|
||||
} else {
|
||||
// Check for unsaved annotation changes
|
||||
const hasAnnotationChanges = historyApiRef?.current?.canUndo() ?? false;
|
||||
|
||||
const enterRedactionMode = async () => {
|
||||
await saveAnnotationsIfNeeded();
|
||||
|
||||
// Set redaction config to manual mode when opening from viewer
|
||||
const manualConfig: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: "manual",
|
||||
};
|
||||
setRedactionConfig(manualConfig);
|
||||
|
||||
// Set tool and keep viewer workbench
|
||||
navActions.setToolAndWorkbench("redact", "viewer");
|
||||
|
||||
// Ensure sidebars are visible and open tool content
|
||||
setSidebarsVisible(true);
|
||||
setLeftPanelView("toolContent");
|
||||
|
||||
setRedactionMode(true);
|
||||
// Activate unified redact mode after a short delay
|
||||
setTimeout(() => {
|
||||
const currentType = redactionApiRef.current?.getActiveType?.();
|
||||
if (currentType !== RedactionMode.Redact) {
|
||||
@@ -171,7 +160,6 @@ export default function ViewerAnnotationControls({
|
||||
};
|
||||
|
||||
const handleToggleAnnotationsVisibility = useCallback(() => {
|
||||
// When going from visible → hidden with unsaved changes, prompt to save first
|
||||
if (!annotationsHidden && hasUnsavedChanges) {
|
||||
requestNavigation(() => viewerContext?.toggleAnnotationsVisibility());
|
||||
} else {
|
||||
@@ -179,7 +167,6 @@ export default function ViewerAnnotationControls({
|
||||
}
|
||||
}, [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
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
@@ -187,15 +174,14 @@ export default function ViewerAnnotationControls({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Redaction Mode Toggle */}
|
||||
<Tooltip
|
||||
content={
|
||||
isRedactMode
|
||||
? t("rightRail.exitRedaction", "Exit Redaction Mode")
|
||||
: t("rightRail.redact", "Redact")
|
||||
? t("workbenchBar.exitRedaction", "Exit Redaction Mode")
|
||||
: t("workbenchBar.redact", "Redact")
|
||||
}
|
||||
position={tooltipPosition}
|
||||
offset={tooltipOffset}
|
||||
position="bottom"
|
||||
offset={16}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
@@ -203,26 +189,25 @@ export default function ViewerAnnotationControls({
|
||||
variant={isRedactMode ? "filled" : "subtle"}
|
||||
color={isRedactMode ? "blue" : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={handleRedactionToggle}
|
||||
disabled={disabled || currentView !== "viewer"}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="scan-delete-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"rightRail.toggleAnnotations",
|
||||
"workbenchBar.toggleAnnotations",
|
||||
"Toggle Annotations Visibility",
|
||||
)}
|
||||
position={tooltipPosition}
|
||||
offset={tooltipOffset}
|
||||
position="bottom"
|
||||
offset={16}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
@@ -230,7 +215,7 @@ export default function ViewerAnnotationControls({
|
||||
variant={annotationsHidden ? "filled" : "subtle"}
|
||||
color={annotationsHidden ? "blue" : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={handleToggleAnnotationsVisibility}
|
||||
disabled={
|
||||
disabled ||
|
||||
@@ -247,8 +232,8 @@ export default function ViewerAnnotationControls({
|
||||
? "visibility"
|
||||
: "preview-off-rounded"
|
||||
}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</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 { useTranslation } from "react-i18next";
|
||||
|
||||
import type { FileTypeMeta } from "@app/components/viewer/nonpdf/types";
|
||||
|
||||
interface NonPdfBannerProps {
|
||||
meta: FileTypeMeta;
|
||||
onConvertToPdf?: () => void;
|
||||
}
|
||||
|
||||
export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) {
|
||||
export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!onConvertToPdf) return null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PictureAsPdfIcon style={{ fontSize: "0.9rem" }} />}
|
||||
onClick={onConvertToPdf}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
variant="default"
|
||||
size="lg"
|
||||
leftSection={
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{meta.icon}
|
||||
</Box>
|
||||
}
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{t("viewer.nonPdf.fileTypeBadge", { type: meta.label })}
|
||||
</Badge>
|
||||
{onConvertToPdf && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PictureAsPdfIcon style={{ fontSize: "0.9rem" }} />}
|
||||
onClick={onConvertToPdf}
|
||||
>
|
||||
{t("viewer.nonPdf.convertToPdf")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{t("viewer.nonPdf.convertToPdf")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,10 +43,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
{/* Toolbar */}
|
||||
<Paper
|
||||
radius={0}
|
||||
p="xs"
|
||||
px="sm"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
flexShrink: 0,
|
||||
minHeight: 44,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
|
||||
+58
-60
@@ -4,15 +4,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { supportedLanguages } from "@app/i18n";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import {
|
||||
useRightRailButtons,
|
||||
RightRailButtonWithAction,
|
||||
} from "@app/hooks/useRightRailButtons";
|
||||
useWorkbenchBarButtons,
|
||||
WorkbenchBarButtonWithAction,
|
||||
} from "@app/hooks/useWorkbenchBarButtons";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
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 { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
|
||||
import { useWorkbenchBarTooltipSide } from "@app/hooks/useWorkbenchBarTooltipSide";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
useNavigationState,
|
||||
@@ -27,7 +27,7 @@ import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
||||
import StopIcon from "@mui/icons-material/Stop";
|
||||
import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud";
|
||||
|
||||
export function useViewerRightRailButtons(
|
||||
export function useViewerWorkbenchBarButtons(
|
||||
isRulerActive?: boolean,
|
||||
setIsRulerActive?: (v: boolean) => void,
|
||||
) {
|
||||
@@ -46,7 +46,7 @@ export function useViewerRightRailButtons(
|
||||
} = viewer;
|
||||
const [isPanning, setIsPanning] = useState<boolean>(false);
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition } = useRightRailTooltipSide(
|
||||
const { position: tooltipPosition } = useWorkbenchBarTooltipSide(
|
||||
sidebarRefs,
|
||||
12,
|
||||
);
|
||||
@@ -105,28 +105,27 @@ export function useViewerRightRailButtons(
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [isAnnotationsPath]);
|
||||
|
||||
const searchLabel = t("rightRail.search", "Search PDF");
|
||||
const panLabel = t("rightRail.panMode", "Pan Mode");
|
||||
const searchLabel = t("workbenchBar.search", "Search PDF");
|
||||
const panLabel = t("workbenchBar.panMode", "Pan Mode");
|
||||
const applyRedactionsLabel = t(
|
||||
"rightRail.applyRedactionsFirst",
|
||||
"workbenchBar.applyRedactionsFirst",
|
||||
"Apply redactions first",
|
||||
);
|
||||
const rotateLeftLabel = t("rightRail.rotateLeft", "Rotate Left");
|
||||
const rotateRightLabel = t("rightRail.rotateRight", "Rotate Right");
|
||||
const sidebarLabel = t("rightRail.toggleSidebar", "Toggle Sidebar");
|
||||
const bookmarkLabel = t("rightRail.toggleBookmarks", "Toggle Bookmarks");
|
||||
const rotateLeftLabel = t("workbenchBar.rotateLeft", "Rotate Left");
|
||||
const rotateRightLabel = t("workbenchBar.rotateRight", "Rotate Right");
|
||||
const sidebarLabel = t("workbenchBar.toggleSidebar", "Toggle Sidebar");
|
||||
const bookmarkLabel = t("workbenchBar.toggleBookmarks", "Toggle Bookmarks");
|
||||
const attachmentLabel = t(
|
||||
"rightRail.toggleAttachments",
|
||||
"workbenchBar.toggleAttachments",
|
||||
"Toggle Attachments",
|
||||
);
|
||||
const layersLabel = t("rightRail.toggleLayers", "Toggle Layers");
|
||||
const commentsLabel = t("rightRail.toggleComments", "Comments");
|
||||
const printLabel = t("rightRail.print", "Print PDF");
|
||||
const annotationsLabel = t("rightRail.annotations", "Annotations");
|
||||
const formFillLabel = t("rightRail.formFill", "Fill Form");
|
||||
const rulerLabel = t("rightRail.ruler", "Ruler / Measure");
|
||||
const readAloudLabel = t("rightRail.readAloud", "Read Aloud");
|
||||
const readAloudSpeedLabel = t("rightRail.readAloudSpeed", "Speed");
|
||||
const layersLabel = t("workbenchBar.toggleLayers", "Toggle Layers");
|
||||
const commentsLabel = t("workbenchBar.toggleComments", "Comments");
|
||||
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
|
||||
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
|
||||
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
|
||||
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
|
||||
const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed");
|
||||
|
||||
const isFormFillActive = (selectedTool as string) === "formFill";
|
||||
|
||||
@@ -150,8 +149,8 @@ export function useViewerRightRailButtons(
|
||||
const shouldShowLanguageSelector =
|
||||
supportedLanguageCodes.size === 0 || filteredLanguages.length > 1;
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
const buttons: RightRailButtonWithAction[] = [
|
||||
const viewerButtons = useMemo<WorkbenchBarButtonWithAction[]>(() => {
|
||||
const buttons: WorkbenchBarButtonWithAction[] = [
|
||||
{
|
||||
id: "viewer-search",
|
||||
tooltip: searchLabel,
|
||||
@@ -179,12 +178,12 @@ export function useViewerRightRailButtons(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
className="workbench-bar-action-icon"
|
||||
disabled={disabled}
|
||||
aria-label={searchLabel}
|
||||
onClick={viewer.searchInterfaceActions.toggle}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
<LocalIcon icon="search" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
@@ -203,7 +202,7 @@ export function useViewerRightRailButtons(
|
||||
{
|
||||
id: "viewer-pan-mode",
|
||||
icon: (
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.25rem" height="1.25rem" />
|
||||
),
|
||||
tooltip:
|
||||
!isPanning && pendingCount > 0 && redactionActiveType !== null
|
||||
@@ -220,12 +219,16 @@ export function useViewerRightRailButtons(
|
||||
!isPanning && pendingCount > 0 && redactionActiveType !== null,
|
||||
onClick: () => {
|
||||
viewer.panActions.togglePan();
|
||||
setIsPanning((prev) => !prev);
|
||||
setIsPanning((prev) => {
|
||||
const next = !prev;
|
||||
if (next && isRulerActive) setIsRulerActive?.(false);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "viewer-ruler",
|
||||
icon: <StraightenIcon sx={{ fontSize: "1.5rem" }} />,
|
||||
icon: <StraightenIcon sx={{ fontSize: "1.25rem" }} />,
|
||||
tooltip: rulerLabel,
|
||||
ariaLabel: rulerLabel,
|
||||
section: "top" as const,
|
||||
@@ -236,12 +239,13 @@ export function useViewerRightRailButtons(
|
||||
setIsRulerActive?.(next);
|
||||
if (next && isPanning) {
|
||||
viewer.panActions.disablePan();
|
||||
setIsPanning(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "viewer-rotate-left",
|
||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="rotate-left" width="1.25rem" height="1.25rem" />,
|
||||
tooltip: rotateLeftLabel,
|
||||
ariaLabel: rotateLeftLabel,
|
||||
section: "top" as const,
|
||||
@@ -252,7 +256,9 @@ export function useViewerRightRailButtons(
|
||||
},
|
||||
{
|
||||
id: "viewer-rotate-right",
|
||||
icon: <LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon icon="rotate-right" width="1.25rem" height="1.25rem" />
|
||||
),
|
||||
tooltip: rotateRightLabel,
|
||||
ariaLabel: rotateRightLabel,
|
||||
section: "top" as const,
|
||||
@@ -263,7 +269,7 @@ export function useViewerRightRailButtons(
|
||||
},
|
||||
{
|
||||
id: "viewer-toggle-sidebar",
|
||||
icon: <LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="view-list" width="1.25rem" height="1.25rem" />,
|
||||
tooltip: sidebarLabel,
|
||||
ariaLabel: sidebarLabel,
|
||||
section: "top" as const,
|
||||
@@ -278,8 +284,8 @@ export function useViewerRightRailButtons(
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="bookmark-add-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
),
|
||||
tooltip: bookmarkLabel,
|
||||
@@ -294,7 +300,11 @@ export function useViewerRightRailButtons(
|
||||
{
|
||||
id: "viewer-toggle-attachments",
|
||||
icon: (
|
||||
<LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />
|
||||
<LocalIcon
|
||||
icon="attachment-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
),
|
||||
tooltip: attachmentLabel,
|
||||
ariaLabel: attachmentLabel,
|
||||
@@ -309,7 +319,7 @@ export function useViewerRightRailButtons(
|
||||
? [
|
||||
{
|
||||
id: "viewer-toggle-layers",
|
||||
icon: <LayersIcon sx={{ fontSize: "1.5rem" }} />,
|
||||
icon: <LayersIcon sx={{ fontSize: "1.25rem" }} />,
|
||||
tooltip: layersLabel,
|
||||
ariaLabel: layersLabel,
|
||||
section: "top" as const,
|
||||
@@ -323,7 +333,7 @@ export function useViewerRightRailButtons(
|
||||
: []),
|
||||
{
|
||||
id: "viewer-toggle-comments",
|
||||
icon: <LocalIcon icon="comment" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="comment" width="1.25rem" height="1.25rem" />,
|
||||
tooltip: commentsLabel,
|
||||
ariaLabel: commentsLabel,
|
||||
section: "top" as const,
|
||||
@@ -333,17 +343,6 @@ export function useViewerRightRailButtons(
|
||||
toggleCommentsSidebar();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "viewer-print",
|
||||
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: printLabel,
|
||||
ariaLabel: printLabel,
|
||||
section: "top" as const,
|
||||
order: 57,
|
||||
onClick: () => {
|
||||
viewer.printActions.print();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "viewer-read-aloud",
|
||||
tooltip: readAloudLabel,
|
||||
@@ -373,7 +372,7 @@ export function useViewerRightRailButtons(
|
||||
<ActionIcon
|
||||
variant={isReadingAloud ? "filled" : "subtle"}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
className="workbench-bar-action-icon"
|
||||
disabled={
|
||||
disabled ||
|
||||
typeof window === "undefined" ||
|
||||
@@ -384,9 +383,9 @@ export function useViewerRightRailButtons(
|
||||
color={isReadingAloud ? "blue" : undefined}
|
||||
>
|
||||
{isReadingAloud ? (
|
||||
<StopIcon sx={{ fontSize: "1.5rem" }} />
|
||||
<StopIcon sx={{ fontSize: "1.25rem" }} />
|
||||
) : (
|
||||
<VolumeUpIcon sx={{ fontSize: "1.5rem" }} />
|
||||
<VolumeUpIcon sx={{ fontSize: "1.25rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -421,9 +420,9 @@ export function useViewerRightRailButtons(
|
||||
/>
|
||||
{shouldShowLanguageSelector && (
|
||||
<Select
|
||||
label={t("rightRail.readAloudLanguage", "Language")}
|
||||
label={t("workbenchBar.readAloudLanguage", "Language")}
|
||||
placeholder={t(
|
||||
"rightRail.selectLanguage",
|
||||
"workbenchBar.selectLanguage",
|
||||
"Select language",
|
||||
)}
|
||||
value={speechLanguage}
|
||||
@@ -461,7 +460,7 @@ export function useViewerRightRailButtons(
|
||||
<ActionIcon
|
||||
variant={isAnnotationsActive ? "filled" : "subtle"}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => {
|
||||
if (disabled || isAnnotationsActive) return;
|
||||
|
||||
@@ -489,7 +488,7 @@ export function useViewerRightRailButtons(
|
||||
aria-pressed={isAnnotationsActive}
|
||||
color={isAnnotationsActive ? "blue" : undefined}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
<LocalIcon icon="edit" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -519,7 +518,7 @@ export function useViewerRightRailButtons(
|
||||
<ActionIcon
|
||||
variant={isFormFillActive ? "filled" : "subtle"}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (isFormFillActive) {
|
||||
@@ -532,7 +531,7 @@ export function useViewerRightRailButtons(
|
||||
aria-pressed={isFormFillActive}
|
||||
color={isFormFillActive ? "blue" : undefined}
|
||||
>
|
||||
<TextFieldsIcon sx={{ fontSize: "1.5rem" }} />
|
||||
<TextFieldsIcon sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -560,7 +559,6 @@ export function useViewerRightRailButtons(
|
||||
bookmarkLabel,
|
||||
attachmentLabel,
|
||||
layersLabel,
|
||||
printLabel,
|
||||
tooltipPosition,
|
||||
annotationsLabel,
|
||||
isAnnotationsActive,
|
||||
@@ -586,5 +584,5 @@ export function useViewerRightRailButtons(
|
||||
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