diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx index cb2ee56527..5fc62680b3 100644 --- a/frontend/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx @@ -68,7 +68,7 @@ const AddFileCard = ({ />
{activeStirlingFileStubs.length === 0 ? (
- - - 📁 - - No files loaded - - Upload PDF files, ZIP archives, or load from storage to get - started - - +
) : (
0 && h > 0) { - return isLandscape ? `${h} / ${w}` : `${w} / ${h}`; + // 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"; })(); diff --git a/frontend/src/core/components/layout/Workbench.module.css b/frontend/src/core/components/layout/Workbench.module.css index 73d2e402f4..51138d2ac3 100644 --- a/frontend/src/core/components/layout/Workbench.module.css +++ b/frontend/src/core/components/layout/Workbench.module.css @@ -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; diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 0385a7536c..69d6f9f410 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Box } from "@mantine/core"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; @@ -19,7 +20,6 @@ import Viewer from "@app/components/viewer/Viewer"; import Footer from "@app/components/shared/Footer"; import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton"; import LandingPage from "@app/components/shared/LandingPage"; - // No props needed - component uses contexts directly export default function Workbench() { const { isRainbowMode } = useRainbowThemeContext(); @@ -50,6 +50,15 @@ export default function Workbench() { const { toolRegistry } = useToolWorkflow(); const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null; const { addFiles } = useFileHandler(); + const hasFiles = activeFiles.length > 0; + + // 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); @@ -82,7 +91,7 @@ export default function Workbench() { } } - if (activeFiles.length === 0) { + if (activeFiles.length === 0) { return ; } @@ -168,14 +177,22 @@ export default function Workbench() { : { backgroundColor: "var(--bg-background)" } } > - {/* Workbench Bar - replaces TopControls and includes RightRail action buttons */} + {/* Workbench Bar - animates in/out based on file presence */} {!customWorkbenchViews.find((v) => v.workbenchId === currentView) ?.hideTopControls && ( - 0} - /> +
+
+ +
+
)} {/* Dismiss All Errors Button */} diff --git a/frontend/src/core/components/pageEditor/PageEditor.tsx b/frontend/src/core/components/pageEditor/PageEditor.tsx index 00e8e2ee05..8f31ca62e9 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/src/core/components/pageEditor/PageEditor.tsx @@ -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; @@ -676,7 +686,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { {!initialDocument && !globalProcessing && - selectedFileIds.length === 0 && ( + !hasPdfFiles && (
@@ -690,7 +700,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
)} - {!initialDocument && globalProcessing && ( + {!initialDocument && (globalProcessing || hasPdfFiles) && ( diff --git a/frontend/src/core/components/shared/FileSidebar.css b/frontend/src/core/components/shared/FileSidebar.css index bf2f232846..144d316de4 100644 --- a/frontend/src/core/components/shared/FileSidebar.css +++ b/frontend/src/core/components/shared/FileSidebar.css @@ -82,6 +82,10 @@ flex-shrink: 0; } +.file-sidebar-search-close { + cursor: pointer; +} + .file-sidebar-search-input { flex: 1; background: transparent; diff --git a/frontend/src/core/components/shared/FileSidebar.tsx b/frontend/src/core/components/shared/FileSidebar.tsx index 872571de21..821aba8cb5 100644 --- a/frontend/src/core/components/shared/FileSidebar.tsx +++ b/frontend/src/core/components/shared/FileSidebar.tsx @@ -66,7 +66,8 @@ const FileSidebar = forwardRef( const { state } = useFileState(); const { actions: fileActions } = useFileActions(); const { actions: navActions } = useNavigationActions(); - const { workbench: currentWorkbench } = useNavigationState(); + const { workbench: currentWorkbench, selectedTool } = useNavigationState(); + const isMultiTool = currentWorkbench === "pageEditor" && selectedTool === "multiTool"; const { requestNavigation } = useNavigationGuard(); const { activeFileId, setActiveFileId } = useViewer(); const { addFiles } = useFileHandler(); @@ -158,9 +159,11 @@ const FileSidebar = forwardRef( const files = await openGoogleDrivePicker({ multiple: true }); if (files.length > 0) { await addFiles(files); - navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + if (!isMultiTool) { + navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + } } - }, [isGoogleDriveEnabled, openGoogleDrivePicker, addFiles, navActions]); + }, [isGoogleDriveEnabled, openGoogleDrivePicker, addFiles, navActions, isMultiTool]); // Toggle file in/out of workbench const handleFileClick = useCallback( @@ -193,21 +196,30 @@ const FileSidebar = forwardRef( await fileActions.addStirlingFileStubs([stub]); - if (workbenchCount === 0) { - navActions.setWorkbench("viewer"); + if (isMultiTool) { + fileActions.setSelectedFiles([ + ...state.ui.selectedFileIds, + stub.id, + ]); } else { - navActions.setWorkbench("fileEditor"); + if (workbenchCount === 0) { + navActions.setWorkbench("viewer"); + } else { + navActions.setWorkbench("fileEditor"); + } } } }, [ allFileStubs, state.files.ids, + state.ui.selectedFileIds, fileActions, navActions, currentWorkbench, activeFileId, requestNavigation, + isMultiTool, ], ); @@ -274,11 +286,13 @@ const FileSidebar = forwardRef( const files = Array.from(e.target.files ?? []); if (files.length > 0) { await addFiles(files); - navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + if (!isMultiTool) { + navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + } } e.target.value = ""; }, - [addFiles, navActions], + [addFiles, navActions, isMultiTool], ); const shouldHideGoogleDrive = diff --git a/frontend/src/core/components/shared/FileSidebarFileItem.css b/frontend/src/core/components/shared/FileSidebarFileItem.css index 8de6de4881..3b6e748d80 100644 --- a/frontend/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/src/core/components/shared/FileSidebarFileItem.css @@ -207,6 +207,20 @@ 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; diff --git a/frontend/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/src/core/components/shared/FileSidebarFileItem.tsx index ec1e55cae7..53f49e0cee 100644 --- a/frontend/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/src/core/components/shared/FileSidebarFileItem.tsx @@ -56,6 +56,43 @@ export function getFileExtension(name: string): string { 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(); @@ -102,21 +139,23 @@ function FilePdfIcon({ ); } @@ -200,7 +239,7 @@ export function FileItem({ const ext = getFileExtension(name); const isPdf = ext === "pdf"; const dateLabel = lastModified ? formatFileDate(lastModified) : ""; - const typeLabel = ext ? ext.toUpperCase() : "File"; + const typeLabel = isPdf ? "" : ext ? ext.toUpperCase() : "File"; const resolvedThumbnail = useLazyThumbnail(fileId, size ?? 0, thumbnailUrl); @@ -245,7 +284,7 @@ export function FileItem({ {isPdf ? ( ) : ( , }, + ...(selectedTool === "multiTool" + ? [ + { + value: "pageEditor" as WorkbenchType, + label: t("workbenchBar.multiTool", "Multi-Tool"), + icon: ( + + ), + }, + ] + : []), + ...customWorkbenchViews + .filter((v) => v.data != null) + .map((v) => ({ + value: v.workbenchId, + label: v.label, + icon: v.icon ?? , + })), ]; const barRef = useRef(null); @@ -366,7 +391,7 @@ export default function WorkbenchBar({
)} - {/* Right: Global buttons (share / delete / download / zoom + page nav) */} + {/* Right: Global buttons — export group left, close anchored right */}
{/* Print */} {currentView === "viewer" && @@ -386,29 +411,6 @@ export default function WorkbenchBar({ t("rightRail.print", "Print PDF"), )} - {/* Close (context-aware: close all / close viewer file / close page editor) */} - {renderWithTooltip( - - - , - currentView === "fileEditor" - ? t("rightRail.closeAll", "Close All") - : t("rightRail.closePdf", "Close PDF"), - )} - {/* Download */} {renderWithTooltip( , t("rightRail.saveAs", "Save As"), )} + + {/* Separator: export group | close */} + {!isCustomView &&
} + + {/* Close (context-aware: close all / close viewer file / close page editor) */} + {!isCustomView && renderWithTooltip( + + + , + currentView === "fileEditor" + ? t("rightRail.closeAll", "Close All") + : t("rightRail.closePdf", "Close PDF"), + )}
); diff --git a/frontend/src/core/components/tools/automate/AutomationRun.tsx b/frontend/src/core/components/tools/automate/AutomationRun.tsx index d988f1a4a8..3e3a4c0756 100644 --- a/frontend/src/core/components/tools/automate/AutomationRun.tsx +++ b/frontend/src/core/components/tools/automate/AutomationRun.tsx @@ -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(); diff --git a/frontend/src/core/components/viewer/NonPdfViewer.tsx b/frontend/src/core/components/viewer/NonPdfViewer.tsx index 16f08fd171..2f187975dd 100644 --- a/frontend/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/src/core/components/viewer/NonPdfViewer.tsx @@ -111,7 +111,6 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) { }} > void; } -export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) { +export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) { const { t } = useTranslation(); + if (!onConvertToPdf) return null; + return ( - } + onClick={onConvertToPdf} style={{ position: "absolute", top: 8, right: 8, zIndex: 10, - pointerEvents: "auto", }} > - - {meta.icon} - - } - style={{ fontWeight: 600 }} - > - {t("viewer.nonPdf.fileTypeBadge", { type: meta.label })} - - {onConvertToPdf && ( - - )} - + {t("viewer.nonPdf.convertToPdf")} + ); } diff --git a/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx b/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx index da5c51d21a..5a3df42dbe 100644 --- a/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx +++ b/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx @@ -182,10 +182,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) { {/* Toolbar */} diff --git a/frontend/src/core/contexts/FilesModalContext.tsx b/frontend/src/core/contexts/FilesModalContext.tsx index d1b50525d7..fc8ed8284a 100644 --- a/frontend/src/core/contexts/FilesModalContext.tsx +++ b/frontend/src/core/contexts/FilesModalContext.tsx @@ -8,7 +8,10 @@ import React, { import { useFileHandler } from "@app/hooks/useFileHandler"; import { useFileActions } from "@app/contexts/FileContext"; import { useFileContext } from "@app/contexts/file/fileHooks"; -import { useNavigationActions } from "@app/contexts/NavigationContext"; +import { + useNavigationActions, + useNavigationState, +} from "@app/contexts/NavigationContext"; import { StirlingFileStub } from "@app/types/fileContext"; import type { FileId } from "@app/types/file"; import { fileStorage } from "@app/services/fileStorage"; @@ -46,6 +49,8 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ const { actions } = useFileActions(); const fileCtx = useFileContext(); const { actions: navActions } = useNavigationActions(); + const { workbench: currentWorkbench, selectedTool } = useNavigationState(); + const isMultiTool = currentWorkbench === "pageEditor" && selectedTool === "multiTool"; const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); const [insertAfterPage, setInsertAfterPage] = useState(); @@ -263,8 +268,10 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ); actions.setSelectedFiles(nextSelection); } - // Single file → viewer, multiple → active files - navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + // Stay in multi-tool; otherwise single file → viewer, multiple → active files + if (!isMultiTool) { + navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + } } closeFilesModal(); }, @@ -276,6 +283,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ actions, fileCtx, navActions, + isMultiTool, ], ); @@ -407,9 +415,11 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ console.error("addStirlingFileStubs action not available"); } - // Single file → viewer, multiple → active files - const totalAdded = stirlingFileStubs.length; - navActions.setWorkbench(totalAdded === 1 ? "viewer" : "fileEditor"); + // Stay in multi-tool; otherwise single file → viewer, multiple → active files + if (!isMultiTool) { + const totalAdded = stirlingFileStubs.length; + navActions.setWorkbench(totalAdded === 1 ? "viewer" : "fileEditor"); + } closeFilesModal(); }, @@ -425,6 +435,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ extractLatestFilesFromBundle, importBundleToWorkbench, navActions, + isMultiTool, ], ); diff --git a/frontend/src/core/contexts/PageEditorContext.tsx b/frontend/src/core/contexts/PageEditorContext.tsx index 0386ab2421..330ec8fb08 100644 --- a/frontend/src/core/contexts/PageEditorContext.tsx +++ b/frontend/src/core/contexts/PageEditorContext.tsx @@ -214,6 +214,8 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) { return [...validOrder, ...newIds]; }); clearPersistedDocument(); + // Select all workbench files so they render as expanded pages + fileActions.setSelectedFiles(state.files.ids); } prevWorkbenchRef.current = nextWorkbench; @@ -223,6 +225,7 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) { state.files.ids, setFileOrder, setReorderedPages, + fileActions, ]); const fileContextSignature = useMemo(() => { diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index bf410c11b6..6502e48da4 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -355,6 +355,22 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setLeftPanelView, ]); + // When in multi-tool, sync left panel visibility with workbench: + // hide the panel on pageEditor, show it when navigating to viewer/fileEditor. + const prevMultiToolWorkbenchRef = React.useRef(null); + useEffect(() => { + const prev = prevMultiToolWorkbenchRef.current; + prevMultiToolWorkbenchRef.current = navigationState.workbench; + + if (navigationState.selectedTool !== "multiTool") return; + + if (navigationState.workbench === "pageEditor" && prev !== "pageEditor") { + setLeftPanelView("hidden"); + } else if (navigationState.workbench !== "pageEditor" && prev === "pageEditor") { + setLeftPanelView("toolPicker"); + } + }, [navigationState.workbench, navigationState.selectedTool, setLeftPanelView]); + // Tool reset methods const registerToolReset = useCallback( (toolId: string, resetFunction: () => void) => { diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index feafd15b76..673a88ec06 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -874,9 +874,13 @@ export async function addStirlingFileStubs( updates, stateRef, ); + return; } } } + + // Stub dispatch triggers re-render so the viewer appears (ADD_FILES alone doesn't update selectors). + lifecycleManager.updateStirlingFileStub(fileId, {}, stateRef); }); } diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index 8df6672531..20edf23935 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -459,7 +459,7 @@ export const useToolOperation = ( inputStirlingFileStubs[0], newToolOperation, file, - thumbnail, + metadata?.thumbnailUrl || thumbnail, metadata, ), ); @@ -523,7 +523,7 @@ export const useToolOperation = ( createNewStirlingFileStub( file, undefined, - thumbnail, + metadata?.thumbnailUrl || thumbnail, metadata, ), ); diff --git a/frontend/src/core/tools/Annotate.tsx b/frontend/src/core/tools/Annotate.tsx index d44a202072..0d7b4a275b 100644 --- a/frontend/src/core/tools/Annotate.tsx +++ b/frontend/src/core/tools/Annotate.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import { useNavigation } from "@app/contexts/NavigationContext"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { BaseToolProps } from "@app/types/tool"; import { useSignature } from "@app/contexts/SignatureContext"; import { ViewerContext, useViewer } from "@app/contexts/ViewerContext"; @@ -73,7 +73,7 @@ const isKnownAnnotationTool = ( const Annotate = (_props: BaseToolProps) => { const { t } = useTranslation(); const { selectedTool, workbench, hasUnsavedChanges } = useNavigation(); - const { selectedFiles } = useFileSelection(); + const { files: allFiles } = useAllFiles(); const { signatureApiRef, annotationApiRef, @@ -677,7 +677,7 @@ const Annotate = (_props: BaseToolProps) => { }); const steps = - selectedFiles.length === 0 + allFiles.length === 0 ? [] : [ { @@ -720,7 +720,7 @@ const Annotate = (_props: BaseToolProps) => { ]; return createToolFlow({ files: { - selectedFiles, + selectedFiles: allFiles, isCollapsed: false, }, steps, diff --git a/frontend/src/core/tools/Automate.tsx b/frontend/src/core/tools/Automate.tsx index 4140f3c7ce..a56fb6368f 100644 --- a/frontend/src/core/tools/Automate.tsx +++ b/frontend/src/core/tools/Automate.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; @@ -24,7 +24,7 @@ import { AUTOMATION_STEPS } from "@app/constants/automation"; const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const { actions } = useNavigationActions(); const { registerToolReset } = useToolWorkflow(); diff --git a/frontend/src/core/utils/pdfiumPageRender.ts b/frontend/src/core/utils/pdfiumPageRender.ts index 0577245c98..1235653405 100644 --- a/frontend/src/core/utils/pdfiumPageRender.ts +++ b/frontend/src/core/utils/pdfiumPageRender.ts @@ -39,21 +39,25 @@ export async function renderPdfiumPageDataUrl( if (!pagePtr) return null; try { + // Effective dims already account for /Rotate; PDFium auto-applies it during render. const rawW = m.FPDF_GetPageWidthF(pagePtr); const rawH = m.FPDF_GetPageHeightF(pagePtr); - // FPDFPage_GetRotation returns 0..3 for 0°/90°/180°/270° CW. + // Returns 0–3 for 0°/90°/180°/270° CW. const pageRotQuarters = (m as any).FPDFPage_GetRotation(pagePtr) | 0; const isQuarterTurn = pageRotQuarters === 1 || pageRotQuarters === 3; - const outW = applyRotation && isQuarterTurn ? rawH : rawW; - const outH = applyRotation && isQuarterTurn ? rawW : rawH; + + // false: swap dims back to raw + counter-rotate to cancel PDFium's auto /Rotate. + const outW = applyRotation ? rawW : isQuarterTurn ? rawH : rawW; + const outH = applyRotation ? rawH : isQuarterTurn ? rawW : rawH; + const renderRotate = applyRotation ? 0 : (4 - pageRotQuarters) % 4; + const w = Math.max(1, Math.round(outW * scale)); const h = Math.max(1, Math.round(outH * scale)); const bitmapPtr = m.FPDFBitmap_Create(w, h, 1); try { - // White background — PDF content doesn't encode paper colour, so - // unpainted regions would otherwise be transparent. + // White background; PDF content doesn't encode paper colour. m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff); m.FPDF_RenderPageBitmap( bitmapPtr, @@ -62,7 +66,7 @@ export async function renderPdfiumPageDataUrl( 0, w, h, - applyRotation ? pageRotQuarters : 0, + renderRotate, PDFIUM_RENDER_FLAGS, ); @@ -71,19 +75,11 @@ export async function renderPdfiumPageDataUrl( const heap = new Uint8Array((m.pdfium.wasmExports as any).memory.buffer); const pixels = new Uint8ClampedArray(w * h * 4); - // BGRA → RGBA. Direct HEAPU8 indexing is ~100× faster than - // per-pixel m.pdfium.getValue() calls for large bitmaps. + // @embedpdf/pdfium WASM stores pixels in RGBA byte order (not BGRA), + // so copy rows directly without channel swapping. for (let y = 0; y < h; y++) { const srcRow = bufferPtr + y * stride; - const dstRow = y * w * 4; - for (let x = 0; x < w; x++) { - const so = srcRow + x * 4; - const dst = dstRow + x * 4; - pixels[dst] = heap[so + 2]; - pixels[dst + 1] = heap[so + 1]; - pixels[dst + 2] = heap[so]; - pixels[dst + 3] = heap[so + 3]; - } + pixels.set(heap.subarray(srcRow, srcRow + w * 4), y * w * 4); } const canvas = document.createElement("canvas");