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:
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user