UI redesign: multi-tool mode, workbench bar animation, PDFium rendering fixes

- Add multi-tool mode support across FileSidebar, FilesModalContext, PageEditorContext,
  WorkbenchBar, and ToolWorkflowContext — adding files stays in pageEditor view and
  auto-selects files; left panel hides/shows on workbench switch
- Animate WorkbenchBar in/out via CSS grid-template-rows trick when files are added/removed;
  suppress initial transition with rAF flag
- Fix PDFium pixel format (RGBA not BGRA) and rotation logic (effective dims already
  post-rotation; counter-rotate when applyRotation=false)
- Fix PageEditor flash of "No PDF files loaded" by checking file context directly
- Redesign AddFileCard: solid border, rounded corners, shadow hover, focus-visible ring
- Show AddFileCard in FileEditor empty state instead of text stack
- Add date grouping helpers (today/yesterday/thisWeek/thisMonth/older) to FileSidebar
- Redesign FilePdfIcon: solid filled with folded corner, red colour
- Simplify NonPdfBanner: remove file-type badge, keep only convert-to-PDF button
- Fix thumbnail precedence in useToolOperation (prefer metadata.thumbnailUrl)
- Fix fileActions stub dispatch to trigger selector re-render after ADD_FILES
- Switch Annotate to useAllFiles and Automate/AutomationRun to useViewScopedFiles
This commit is contained in:
EthanHealy01
2026-04-30 21:16:58 +01:00
parent eaed8feb42
commit a42fdfad00
25 changed files with 311 additions and 171 deletions
@@ -68,7 +68,7 @@ const AddFileCard = ({
/>
<div
className={`${styles.addFileCard} w-[21rem] h-[26rem] 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")}
@@ -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,6 +1,6 @@
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,
@@ -377,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
@@ -106,7 +106,9 @@ const FileEditorThumbnail = ({
const w = firstPage?.width;
const h = firstPage?.height;
if (w && h && w > 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";
})();
@@ -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,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 <LandingPage />;
}
@@ -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 && (
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={activeFiles.length > 0}
/>
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
</div>
</div>
)}
{/* Dismiss All Errors Button */}
@@ -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 && (
<Center h="100%">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
@@ -690,7 +700,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
</Center>
)}
{!initialDocument && globalProcessing && (
{!initialDocument && (globalProcessing || hasPdfFiles) && (
<Box p={0}>
<SkeletonLoader type="controls" />
<SkeletonLoader type="pageGrid" count={8} />
@@ -82,6 +82,10 @@
flex-shrink: 0;
}
.file-sidebar-search-close {
cursor: pointer;
}
.file-sidebar-search-input {
flex: 1;
background: transparent;
@@ -66,7 +66,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
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<HTMLDivElement, FileSidebarProps>(
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<HTMLDivElement, FileSidebarProps>(
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<HTMLDivElement, FileSidebarProps>(
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 =
@@ -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;
@@ -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({
<svg
className={className}
style={style}
width="20"
width="16"
height="20"
viewBox="0 0 24 24"
viewBox="0 0 16 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
<path d="M9 13h.01" />
<path d="M9 17h.01" />
<path d="M13 13h2" />
<path d="M13 17h2" />
<path
d="M1 2C1 .895 1.895 0 3 0H9.5L15 5.5V18C15 19.105 14.105 20 13 20H3C1.895 20 1 19.105 1 18V2Z"
fill="currentColor"
/>
<path
d="M9.5 0L15 5.5H11C10.172 5.5 9.5 4.828 9.5 4V0Z"
fill="rgba(0,0,0,0.2)"
/>
<rect x="3.5" y="8.5" width="7" height="1.2" rx=".6" fill="rgba(255,255,255,.85)" />
<rect x="3.5" y="11" width="9" height="1.2" rx=".6" fill="rgba(255,255,255,.85)" />
<rect x="3.5" y="13.5" width="5.5" height="1.2" rx=".6" fill="rgba(255,255,255,.85)" />
</svg>
);
}
@@ -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 ? (
<FilePdfIcon
className="file-sidebar-file-icon file-sidebar-file-icon-hover-hide"
style={{ color: "#3B82F6" }}
style={{ color: "#DC2626" }}
/>
) : (
<FileGenericIcon
@@ -152,6 +152,11 @@
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;
@@ -11,8 +11,9 @@ 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 } from "@app/types/workbench";
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";
@@ -66,8 +67,10 @@ export default function WorkbenchBar({
}: WorkbenchBarProps) {
const { t } = useTranslation();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } =
const { pageEditorFunctions, toolPanelMode, leftPanelView, customWorkbenchViews } =
useToolWorkflow();
const { selectedTool } = useNavigationState();
const isCustomView = !isBaseWorkbench(currentView);
const disableForFullscreen =
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const terminology = useFileActionTerminology();
@@ -286,6 +289,28 @@ export default function WorkbenchBar({
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);
@@ -366,7 +391,7 @@ export default function WorkbenchBar({
</div>
)}
{/* Right: Global buttons (share / delete / download / zoom + page nav) */}
{/* Right: Global buttons — export group left, close anchored right */}
<div className="workbench-bar-globals">
{/* 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(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={handleClose}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
aria-label={
currentView === "fileEditor"
? t("rightRail.closeAll", "Close All")
: t("rightRail.closePdf", "Close PDF")
}
>
<CloseIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>,
currentView === "fileEditor"
? t("rightRail.closeAll", "Close All")
: t("rightRail.closePdf", "Close PDF"),
)}
{/* Download */}
{renderWithTooltip(
<ActionIcon
@@ -449,6 +451,32 @@ export default function WorkbenchBar({
</ActionIcon>,
t("rightRail.saveAs", "Save As"),
)}
{/* Separator: export group | close */}
{!isCustomView && <div className="workbench-bar-divider workbench-bar-globals-sep" />}
{/* Close (context-aware: close all / close viewer file / close page editor) */}
{!isCustomView && renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={handleClose}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
aria-label={
currentView === "fileEditor"
? t("rightRail.closeAll", "Close All")
: t("rightRail.closePdf", "Close PDF")
}
>
<CloseIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>,
currentView === "fileEditor"
? t("rightRail.closeAll", "Close All")
: t("rightRail.closePdf", "Close PDF"),
)}
</div>
</div>
);
@@ -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();
@@ -111,7 +111,6 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
}}
>
<NonPdfBanner
meta={meta}
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
/>
<Box
@@ -1,59 +1,31 @@
import { Badge, Box, Button, Group } from "@mantine/core";
import { Button } from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { useTranslation } from "react-i18next";
import type { FileTypeMeta } from "@app/components/viewer/nonpdf/types";
interface NonPdfBannerProps {
meta: FileTypeMeta;
onConvertToPdf?: () => void;
}
export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) {
export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) {
const { t } = useTranslation();
if (!onConvertToPdf) return null;
return (
<Group
gap="xs"
wrap="nowrap"
align="center"
<Button
size="xs"
variant="light"
color="orange"
leftSection={<PictureAsPdfIcon style={{ fontSize: "0.9rem" }} />}
onClick={onConvertToPdf}
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
pointerEvents: "auto",
}}
>
<Badge
variant="default"
size="lg"
leftSection={
<Box
style={{
display: "flex",
alignItems: "center",
fontSize: "0.85rem",
}}
>
{meta.icon}
</Box>
}
style={{ fontWeight: 600 }}
>
{t("viewer.nonPdf.fileTypeBadge", { type: meta.label })}
</Badge>
{onConvertToPdf && (
<Button
size="xs"
variant="light"
color="orange"
leftSection={<PictureAsPdfIcon style={{ fontSize: "0.9rem" }} />}
onClick={onConvertToPdf}
>
{t("viewer.nonPdf.convertToPdf")}
</Button>
)}
</Group>
{t("viewer.nonPdf.convertToPdf")}
</Button>
);
}
@@ -182,10 +182,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
{/* Toolbar */}
<Paper
radius={0}
p="xs"
px="sm"
style={{
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
minHeight: 44,
display: "flex",
alignItems: "center",
}}
>
<Group gap="md" align="center">
@@ -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<number | undefined>();
@@ -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,
],
);
@@ -214,6 +214,8 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
return [...validOrder, ...newIds];
});
clearPersistedDocument();
// Select all workbench files so they render as expanded pages
fileActions.setSelectedFiles(state.files.ids);
}
prevWorkbenchRef.current = nextWorkbench;
@@ -223,6 +225,7 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
state.files.ids,
setFileOrder,
setReorderedPages,
fileActions,
]);
const fileContextSignature = useMemo(() => {
@@ -355,6 +355,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<WorkbenchType | null>(null);
useEffect(() => {
const prev = prevMultiToolWorkbenchRef.current;
prevMultiToolWorkbenchRef.current = navigationState.workbench;
if (navigationState.selectedTool !== "multiTool") return;
if (navigationState.workbench === "pageEditor" && prev !== "pageEditor") {
setLeftPanelView("hidden");
} else if (navigationState.workbench !== "pageEditor" && prev === "pageEditor") {
setLeftPanelView("toolPicker");
}
}, [navigationState.workbench, navigationState.selectedTool, setLeftPanelView]);
// Tool reset methods
const registerToolReset = useCallback(
(toolId: string, resetFunction: () => void) => {
@@ -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);
});
}
@@ -459,7 +459,7 @@ export const useToolOperation = <TParams>(
inputStirlingFileStubs[0],
newToolOperation,
file,
thumbnail,
metadata?.thumbnailUrl || thumbnail,
metadata,
),
);
@@ -523,7 +523,7 @@ export const useToolOperation = <TParams>(
createNewStirlingFileStub(
file,
undefined,
thumbnail,
metadata?.thumbnailUrl || thumbnail,
metadata,
),
);
+4 -4
View File
@@ -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,
+2 -2
View File
@@ -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();
+13 -17
View File
@@ -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");