UI redesign staging (#6149)

Co-authored-by: Reece Browne <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2026-05-15 15:36:50 +01:00
committed by GitHub
co-authored by Reece Browne James Brunton
parent beb99e273b
commit c731d5fd5d
173 changed files with 7290 additions and 4269 deletions
+10 -10
View File
@@ -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
View File
@@ -2,6 +2,8 @@ dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier)
src-tauri/target/
node_modules/
src-tauri/target/
src-tauri/gen/
public/vendor/
public/pdfjs*/
public/js/thirdParty/
+118 -41
View File
@@ -52,8 +52,8 @@ exportAndContinue = "Export & Continue"
false = "False"
fileNotSavedToDisk = "Not saved to disk"
fileSavedToDisk = "File saved to disk"
fileSelected = "Selected: {{filename}}"
filesSelected = "{{count}} files selected"
fileSelected = "{{filename}}"
filesSelected = "{{count}} files"
font = "Font"
fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text."
fontTypeTooltip = "Font family for the page numbers. Choose based on your document style."
@@ -82,7 +82,7 @@ multiPdfPrompt = "Select PDFs (2+)"
never = "Never"
no = "No"
noFavourites = "No favourites added"
noFileSelected = "No file selected. Please upload one."
noFileSelected = "No file loaded. Please upload one."
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
noOperationToUndo = "No operation to undo"
notAuthenticatedMessage = "User not authenticated."
@@ -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 = "AZ"
[fileSelectorPicker.tabs]
saved = "Saved files"
upload = "Upload"
workbench = "Workbench"
[fileSidebar]
addFiles = "Add files"
collapse = "Collapse sidebar"
dropHint = "Open files to get started"
expand = "Expand sidebar"
files = "Files"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
noFiles = "No files yet"
openFileManager = "Open file manager"
openFromComputer = "Open from computer"
openSettings = "Open settings"
search = "Search"
searchPlaceholder = "Search files..."
[fileToPDF]
credit = "This service uses LibreOffice and Unoconv for file conversion."
header = "Convert any file to PDF"
@@ -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"
+1 -2
View File
@@ -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>
+21 -2
View File
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Modal } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { useFileManager } from "@app/hooks/useFileManager";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -17,6 +18,7 @@ import {
} from "@app/services/googleDrivePickerService";
import { loadScript } from "@app/utils/scriptLoader";
import { useAllFiles } from "@app/contexts/FileContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -28,6 +30,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
closeFilesModal,
onFileUpload,
onRecentFileSelect,
maxSelectable,
} = useFilesModalContext();
const { config } = useAppConfig();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
@@ -35,6 +38,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { actions: fileActions } = useFileActions();
// Get active file IDs from FileContext to show which files are already loaded
const { fileIds: activeFileIds } = useAllFiles();
@@ -83,10 +87,23 @@ 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);
},
[handleRemoveFile, recentFiles],
refreshRecentFiles,
);
},
[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);
@@ -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,8 +337,6 @@ const FileEditorThumbnail = ({
onDownloadFile(file.id);
},
},
...(canUpload || canShare
? [
...(canUpload
? [
{
@@ -351,8 +365,6 @@ const FileEditorThumbnail = ({
},
]
: []),
]
: []),
{
id: "unzip",
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
@@ -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,67 +447,80 @@ 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.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 className={styles.unsupportedPill}>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
{/* Centered index */}
<div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
className={styles.thumbContainer}
data-supported={isSupported}
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
>
{index + 1}
{/* Error overlay */}
{hasError && (
<div className={styles.errorOverlay}>
<span className={styles.errorPill}>
{t("error._value", "Error")}
</span>
</div>
)}
{/* Action buttons group */}
<div className={styles.headerActions}>
{/* 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/"))
}
iconSize="6rem"
imgClassName={styles.thumbImage}
onImageError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
{/* 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(
@@ -505,199 +529,48 @@ const FileEditorThumbnail = ({
)}
>
<ActionIcon
aria-label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
variant="subtle"
className={styles.headerIconButton}
size="xs"
variant="filled"
color="yellow"
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
style={{ pointerEvents: "auto" }}
>
<LockOpenIcon fontSize="small" />
<LockOpenIcon style={{ fontSize: 12 }} />
</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,
});
}
}
}}
>
{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>
<FileEditorStatusDot file={file} />
</div>
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={
isSupported || hasError
? undefined
: { filter: "grayscale(80%)", opacity: 0.6 }
}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl ? (
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = "none";
img.parentElement?.setAttribute("data-thumb-missing", "true");
}}
style={{
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
}}
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
</div>
{/* end thumbUnit */}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
{/* Drag handle */}
<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>
)}
</div>
{/* Hover Menu */}
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
<HoverActionMenu
show={showHoverMenu || isMobile}
show={isMobile}
actions={hoverActions}
position="outside"
visibility="cssHover"
/>
</div>
{/* 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,16 +151,19 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
<Button
size="sm"
onClick={onOpenFiles}
disabled={!hasSelection}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor: hasSelection
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
: selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
@@ -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,16 +109,19 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
<Button
size="md"
onClick={onOpenFiles}
disabled={!hasSelection}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor: hasSelection
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
: selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
@@ -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
? "var(--mantine-color-gray-1)"
: shouldShowHovered
cursor: isHistoryFile ? "default" : "pointer",
backgroundColor:
isSelected || shouldShowHovered
? "var(--mantine-color-gray-1)"
: "var(--bg-file-list)",
opacity: isSupported ? 1 : 0.5,
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,26 +187,22 @@ export default function Workbench() {
: { backgroundColor: "var(--bg-background)" }
}
>
{/* Top Controls */}
{activeFiles.length > 0 &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
{/* Workbench Bar - animates in/out based on file presence */}
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<TopControls
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
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}
hasFiles={hasFiles}
/>
</div>
</div>
)}
{/* Dismiss All Errors Button */}
@@ -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,9 +684,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
>
<LoadingOverlay visible={globalProcessing && !initialDocument} />
{!initialDocument &&
!globalProcessing &&
selectedFileIds.length === 0 && (
{!initialDocument && !globalProcessing && !hasPdfFiles && (
<Center h="100%">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
@@ -693,7 +698,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
</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}
>
@@ -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"
<DocumentThumbnail
file={file}
thumbnail={thumb || undefined}
isEncrypted={isEncrypted}
isLoading={isGenerating || isHydrating}
iconSize="3rem"
/>
) : isGenerating || isHydrating ? (
<Stack align="center" justify="center" gap="xs">
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading...
</Text>
</Stack>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>
Large File
</Text>
)}
</div>
)}
</Box>
<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", "AZ")}
{sortBy === "name" && (
<span className={styles.sortArrow}>
{sortDir === "asc" ? " ↑" : " ↓"}
</span>
)}
</button>
</div>
</div>
<div className={styles.searchRow}>
<input
className={styles.searchInput}
type="search"
placeholder={t("fileSelectorPicker.search", "Filter files…")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
<ScrollArea h={260} className={styles.list}>
{savedLoading ? (
<div className={styles.emptyState}>
<Loader size="sm" />
</div>
) : displayStubs.length === 0 ? (
<div className={styles.emptyState}>
<Text size="sm">
{t("fileSelectorPicker.empty", "No files available")}
</Text>
</div>
) : (
displayStubs.map((stub) => {
const meta = buildMeta(stub);
const isItemLoading = loadingId === stub.id;
return (
<button
key={stub.id}
type="button"
className={styles.fileItem}
onClick={() => void loadAndSelect(stub)}
disabled={!!loadingId}
onMouseEnter={(e) =>
setHoveredStub({
rect: e.currentTarget.getBoundingClientRect(),
stub,
})
}
onMouseLeave={() => setHoveredStub(null)}
>
<div className={styles.fileItemContent}>
<span className={styles.fileName} title={stub.name}>
{truncateCenter(stub.name, 48)}
</span>
{meta && <span className={styles.fileMeta}>{meta}</span>}
</div>
{isItemLoading && <Loader size="xs" />}
</button>
);
})
)}
</ScrollArea>
{hoveredStub &&
hoveredThumbnail &&
createPortal(
<div
className="file-sidebar-thumb-tooltip"
style={{
top: hoveredStub.rect.top + hoveredStub.rect.height / 2,
left: hoveredStub.rect.left - 170,
}}
>
<img
src={hoveredThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
</Popover.Dropdown>
</Popover>
{/* Hidden file input lives outside the Popover so it is always in the DOM.
Tests can target it directly with setInputFiles without opening the popover. */}
<input
ref={fileInputRef}
type="file"
data-testid={testId ? `${testId}-input` : "file-selector-upload-input"}
accept=".pdf,application/pdf"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
void handleUpload(file);
e.target.value = "";
}}
/>
</>
);
}
@@ -0,0 +1,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={{
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;
}
}
+109 -49
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
@@ -8,15 +8,18 @@ import ToolRenderer from "@app/components/tools/ToolRenderer";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { ActionIcon, ScrollArea } from "@mantine/core";
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
import { ToolId } from "@app/types/toolId";
import { useIsMobile } from "@app/hooks/useIsMobile";
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
import { useTranslation } from "react-i18next";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import { useRightRail } from "@app/contexts/RightRailContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import { 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"
variant="outline"
radius="xl"
style={{ color: "var(--right-rail-icon)" }}
onClick={handleModeToggle}
aria-label={toggleLabel}
className="tool-panel__mode-toggle"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
style={{ flexShrink: 0 }}
>
<DoubleArrowIcon
fontSize="small"
style={{ transform: isRTL ? "scaleX(-1)" : undefined }}
/>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
</Tooltip>
)}
</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;
@@ -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)) {
@@ -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"
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}
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
}}
>
{t("viewer.nonPdf.convertToPdf")}
</Button>
)}
</Group>
);
}
@@ -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">
@@ -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