frontend validatoin

This commit is contained in:
EthanHealy01
2026-04-21 18:47:23 +01:00
parent 53377da75d
commit 2843418fb7
45 changed files with 1653 additions and 667 deletions
@@ -11,7 +11,9 @@
--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));
padding-bottom: calc(
var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap)
);
user-select: none;
cursor: pointer;
}
@@ -156,8 +158,10 @@
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"] {
.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;
}
@@ -86,7 +86,10 @@ const FileEditorThumbnail = ({
const { state, selectors } = useFileState();
const isMobile = useIsMobile();
const actualFile = useMemo(() => 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;
const isZipFile = zipFileService.isZipFileStub(file);
@@ -212,7 +215,10 @@ const FileEditorThumbnail = ({
[file.id, file.name, onReorderFiles],
);
const handleCloseWithConfirmation = useCallback(() => setShowCloseModal(true), []);
const handleCloseWithConfirmation = useCallback(
() => setShowCloseModal(true),
[],
);
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
const handleConfirmClose = useCallback(() => {
@@ -295,10 +301,20 @@ const FileEditorThumbnail = ({
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
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 });
alert({
alertType: "success",
title: `Pinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
}
}
},
@@ -412,7 +428,9 @@ const FileEditorThumbnail = ({
onViewFile(file.id);
};
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel].filter(Boolean).join(" - ");
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
.filter(Boolean)
.join(" - ");
return (
<div
@@ -454,7 +472,9 @@ const FileEditorThumbnail = ({
{/* Error overlay */}
{hasError && (
<div className={styles.errorOverlay}>
<span className={styles.errorPill}>{t("error._value", "Error")}</span>
<span className={styles.errorPill}>
{t("error._value", "Error")}
</span>
</div>
)}
@@ -474,7 +494,12 @@ const FileEditorThumbnail = ({
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
@@ -484,17 +509,26 @@ const FileEditorThumbnail = ({
{/* Badges — visible on hover via CSS */}
<div className={styles.thumbBadges}>
<span className={styles.versionBadgeThumb}>v{file.versionNumber}</span>
<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>
<span className={styles.ownershipBadge}>
{t("fileManager.sharedWithYou", "Shared")}
</span>
)}
{isEncrypted && (
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
<Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
>
<ActionIcon
size="xs"
variant="filled"
@@ -521,7 +555,12 @@ const FileEditorThumbnail = ({
</div>
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
<HoverActionMenu show={isMobile} actions={hoverActions} position="outside" visibility="cssHover" />
<HoverActionMenu
show={isMobile}
actions={hoverActions}
position="outside"
visibility="cssHover"
/>
</div>
{/* File name + meta */}
@@ -95,7 +95,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : "No file loaded"}</PrivateContent>
<PrivateContent>
{currentFile ? currentFile.name : "No file loaded"}
</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ""}
@@ -152,7 +154,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor: hasSelection || canCloseAll ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
@@ -12,7 +12,8 @@ interface FileDetailsProps {
}
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } = useFileManagerContext();
const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } =
useFileManagerContext();
const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
@@ -111,7 +112,10 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor: hasSelection || canCloseAll ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
@@ -188,7 +188,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
p="sm"
style={{
cursor: isHistoryFile ? "default" : "pointer",
backgroundColor: isSelected || shouldShowHovered ? "var(--mantine-color-gray-1)" : "var(--bg-file-list)",
backgroundColor:
isSelected || shouldShowHovered
? "var(--mantine-color-gray-1)"
: "var(--bg-file-list)",
opacity: isSupported ? 1 : 0.5,
transition: "background-color 0.15s ease",
userSelect: "none",
@@ -169,8 +169,13 @@ export default function Workbench() {
}
>
{/* Workbench Bar - replaces TopControls and includes RightRail action buttons */}
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && (
<WorkbenchBar currentView={currentView} setCurrentView={setCurrentView} hasFiles={activeFiles.length > 0} />
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={activeFiles.length > 0}
/>
)}
{/* Dismiss All Errors Button */}
@@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({
updatePagesFromCSV,
}: PageSelectByNumberButtonProps) {
return (
<Tooltip content={label} position="bottom" offset={12} arrow portalTarget={document.body}>
<Tooltip
content={label}
position="bottom"
offset={12}
arrow
portalTarget={document.body}
>
<div className={`right-rail-fade enter`}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
@@ -245,7 +245,8 @@ const FilePickerModal = ({
{/* Selection summary */}
{selectedFileIds.length > 0 && (
<Text size="sm" c="blue" ta="center">
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
{selectedFileIds.length}{" "}
{t("fileManager.filesSelected", "files selected")}
</Text>
)}
</>
@@ -28,7 +28,8 @@
.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);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent);
}
/* ── Popover dropdown ────────────────────────────── */
@@ -67,7 +68,11 @@
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);
background: color-mix(
in srgb,
var(--mantine-color-default-hover) 85%,
transparent
);
padding: 1px;
gap: 1px;
box-sizing: border-box;
@@ -1,9 +1,20 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Box, Popover, ScrollArea, Text, Loader, FileButton } from "@mantine/core";
import {
Box,
Popover,
ScrollArea,
Text,
Loader,
FileButton,
} from "@mantine/core";
import AddIcon from "@mui/icons-material/Add";
import { useTranslation } from "react-i18next";
import { createStirlingFile, createFileId, createNewStirlingFileStub } from "@app/types/fileContext";
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";
@@ -12,7 +23,10 @@ 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 {
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";
@@ -22,7 +36,11 @@ 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] {
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[];
@@ -50,7 +68,11 @@ function formatBytes(bytes: number): string {
function formatDate(ms: number | undefined): string {
if (!ms) return "";
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
return new Date(ms).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function buildMeta(stub: StirlingFileStub): string {
@@ -81,18 +103,32 @@ export interface FileSelectorPickerProps {
onSelect: (result: FileSelectorResult) => void;
}
export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = false, onSelect }: FileSelectorPickerProps) {
export function FileSelectorPicker({
placeholder,
excludeIds = [],
disabled = false,
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 [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 [hoveredStub, setHoveredStub] = useState<{
rect: DOMRect;
stub: StirlingFileStub;
} | null>(null);
const [hoveredThumbnail, setHoveredThumbnail] = useState<string | null>(null);
const thumbCancelRef = useRef<boolean>(false);
@@ -120,7 +156,10 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
const thumbnail = await generateThumbnailForFile(file);
if (thumbCancelRef.current || !thumbnail) return;
setHoveredThumbnail(thumbnail);
void indexedDB.updateThumbnail(hoveredStub.stub.id as FileId, thumbnail);
void indexedDB.updateThumbnail(
hoveredStub.stub.id as FileId,
thumbnail,
);
} catch {
// non-critical
}
@@ -172,19 +211,35 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
.finally(() => setSavedLoading(false));
}, [activeTab, isOpen, loadRecentFiles]);
const workbenchIdSet = useMemo(() => new Set(workbenchStubs.map((s) => s.id)), [workbenchStubs]);
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 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)),
: dir *
((a.lastModified || a.createdAt || 0) -
(b.lastModified || b.createdAt || 0)),
);
}, [activeTab, workbenchStubs, savedStubs, excludeIds, sortBy, sortDir, searchQuery]);
}, [
activeTab,
workbenchStubs,
savedStubs,
excludeIds,
sortBy,
sortDir,
searchQuery,
]);
const loadAndSelect = useCallback(
async (stub: StirlingFileStub) => {
@@ -195,7 +250,8 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
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;
const workbenchStub =
selectors.getStirlingFileStub(stub.id as FileId) ?? stub;
onSelect({ stub: workbenchStub, stirlingFile: sf });
setIsOpen(false);
}
@@ -208,33 +264,53 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
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 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());
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 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);
if (files[0])
stirlingFile = createStirlingFile(files[0], stub.id as FileId);
} else {
// Local IndexedDB file
const localFile = await fileStorage.getStirlingFile(stub.id);
@@ -250,7 +326,10 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
if (thumbnail) {
resolvedStub = { ...stub, thumbnailUrl: thumbnail };
// Persist so subsequent opens don't regenerate
void fileStorage.updateThumbnail(stirlingFile.fileId as FileId, thumbnail);
void fileStorage.updateThumbnail(
stirlingFile.fileId as FileId,
thumbnail,
);
}
} catch {
// Non-fatal — thumbnail simply won't show
@@ -299,7 +378,11 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
[disabled, loadRecentFiles, onSelect, uploadBusy],
);
const triggerClass = [styles.trigger, disabled ? styles.triggerDisabled : "", isOpen ? styles.triggerOpen : ""]
const triggerClass = [
styles.trigger,
disabled ? styles.triggerDisabled : "",
isOpen ? styles.triggerOpen : "",
]
.filter(Boolean)
.join(" ");
@@ -327,15 +410,31 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if (!disabled && (e.key === "Enter" || e.key === " ")) setIsOpen((o) => !o);
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" }}>
<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 }} />
<AddIcon
style={{
fontSize: 18,
color: "var(--mantine-color-dimmed)",
flexShrink: 0,
}}
/>
</Box>
</Popover.Target>
@@ -345,12 +444,17 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
<div
className={styles.slimTabBar}
role="group"
aria-label={t("fileSelectorPicker.tabListLabel", "Saved files, workbench, upload")}
aria-label={t(
"fileSelectorPicker.tabListLabel",
"Saved files, workbench, upload",
)}
>
<button
type="button"
aria-pressed={activeTab === "saved"}
className={activeTab === "saved" ? styles.slimTabActive : styles.slimTab}
className={
activeTab === "saved" ? styles.slimTabActive : styles.slimTab
}
onClick={() => handleTabChange("saved")}
>
{t("fileSelectorPicker.tabs.saved", "Saved files")}
@@ -358,22 +462,40 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
<button
type="button"
aria-pressed={activeTab === "workbench"}
className={activeTab === "workbench" ? styles.slimTabActive : styles.slimTab}
className={
activeTab === "workbench"
? styles.slimTabActive
: styles.slimTab
}
onClick={() => handleTabChange("workbench")}
>
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
</button>
<FileButton onChange={handleUpload} accept=".pdf,application/pdf" disabled={disabled || uploadBusy}>
<FileButton
onChange={handleUpload}
accept=".pdf,application/pdf"
disabled={disabled || uploadBusy}
>
{(fbProps) => (
<button
{...fbProps}
type="button"
className={styles.slimTabUpload}
aria-label={t("fileSelectorPicker.upload", "Upload from computer")}
title={t("fileSelectorPicker.upload", "Upload from computer")}
aria-label={t(
"fileSelectorPicker.upload",
"Upload from computer",
)}
title={t(
"fileSelectorPicker.upload",
"Upload from computer",
)}
disabled={disabled || uploadBusy}
>
{uploadBusy ? <Loader size={11} /> : t("fileSelectorPicker.tabs.upload", "Upload")}
{uploadBusy ? (
<Loader size={11} />
) : (
t("fileSelectorPicker.tabs.upload", "Upload")
)}
</button>
)}
</FileButton>
@@ -383,7 +505,10 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
<div className={styles.sortGroup}>
<button
type="button"
className={[styles.sortBtn, sortBy === "date" ? styles.sortBtnActive : ""].join(" ")}
className={[
styles.sortBtn,
sortBy === "date" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("date")}
title={
sortDir === "desc"
@@ -392,11 +517,18 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
}
>
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
{sortBy === "date" && <span className={styles.sortArrow}>{sortDir === "desc" ? " ↓" : " ↑"}</span>}
{sortBy === "date" && (
<span className={styles.sortArrow}>
{sortDir === "desc" ? " ↓" : " ↑"}
</span>
)}
</button>
<button
type="button"
className={[styles.sortBtn, sortBy === "name" ? styles.sortBtnActive : ""].join(" ")}
className={[
styles.sortBtn,
sortBy === "name" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("name")}
title={
sortDir === "asc"
@@ -405,7 +537,11 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
}
>
{t("fileSelectorPicker.sort.nameLabel", "A–Z")}
{sortBy === "name" && <span className={styles.sortArrow}>{sortDir === "asc" ? " ↑" : " ↓"}</span>}
{sortBy === "name" && (
<span className={styles.sortArrow}>
{sortDir === "asc" ? " ↑" : " ↓"}
</span>
)}
</button>
</div>
</div>
@@ -429,7 +565,9 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
</div>
) : displayStubs.length === 0 ? (
<div className={styles.emptyState}>
<Text size="sm">{t("fileSelectorPicker.empty", "No files available")}</Text>
<Text size="sm">
{t("fileSelectorPicker.empty", "No files available")}
</Text>
</div>
) : (
displayStubs.map((stub) => {
@@ -442,7 +580,12 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
className={styles.fileItem}
onClick={() => void loadAndSelect(stub)}
disabled={!!loadingId}
onMouseEnter={(e) => setHoveredStub({ rect: e.currentTarget.getBoundingClientRect(), stub })}
onMouseEnter={(e) =>
setHoveredStub({
rect: e.currentTarget.getBoundingClientRect(),
stub,
})
}
onMouseLeave={() => setHoveredStub(null)}
>
<div className={styles.fileItemContent}>
@@ -468,7 +611,11 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
left: hoveredStub.rect.left - 170,
}}
>
<img src={hoveredThumbnail} alt="" className="file-sidebar-thumb-img" />
<img
src={hoveredThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
@@ -1,11 +1,21 @@
import React, { useState, useCallback, useRef, useEffect, forwardRef } from "react";
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 {
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";
@@ -32,425 +42,518 @@ export interface FileSidebarProps {
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 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 } = useNavigationState();
const { requestNavigation } = useNavigationGuard();
const { activeFileId, setActiveFileId } = useViewer();
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
const [displayName, setDisplayName] = useState<string>("Guest");
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 } = useNavigationState();
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);
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
}, [isGoogleDriveEnabled, openGoogleDrivePicker, addFiles, navActions]);
// 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 (workbenchCount === 0) {
navActions.setWorkbench("viewer");
} else {
navActions.setWorkbench("fileEditor");
}
}
},
[allFileStubs, state.files.ids, fileActions, navActions, currentWorkbench, activeFileId, requestNavigation],
);
// 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();
useEffect(() => {
if (!config?.enableLogin) return;
accountService
.getAccountData()
.then((data) => {
if (data?.username) setDisplayName(data.username);
})
.catch(() => {
/* not logged in or security disabled */
});
} else {
await performSwitch();
}
},
[
allFileStubs,
viewedWorkbenchId,
state.files.ids,
fileActions,
navActions,
currentWorkbench,
setPendingViewFileId,
requestNavigation,
],
);
}, [config?.enableLogin]);
const handleNativeFilePick = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
// 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);
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
e.target.value = "";
},
[addFiles, navActions],
);
}, [isGoogleDriveEnabled, openGoogleDrivePicker, addFiles, navActions]);
const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
// Toggle file in/out of workbench
const handleFileClick = useCallback(
async (fileId: FileId) => {
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
);
return (
<div
ref={ref}
className="file-sidebar"
style={{ width, minWidth: width, maxWidth: width }}
data-collapsed={collapsed}
data-sidebar="file-sidebar"
>
<div className="file-sidebar-inner">
{/* Header: hamburger + branding */}
<div
className="file-sidebar-header"
onClick={() => {
if (searchActive) {
handleSearchClose();
} else {
onToggleCollapse?.();
}
}}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()}
aria-label={collapsed ? t("fileSidebar.expand", "Expand sidebar") : t("fileSidebar.collapse", "Collapse sidebar")}
>
{searchActive && !collapsed ? (
<CloseIcon className="file-sidebar-menu-icon" />
) : (
<MenuIcon className="file-sidebar-menu-icon" />
)}
{!collapsed && <span className="file-sidebar-brand-text sidebar-content-fade">Stirling PDF</span>}
</div>
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;
{/* 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}
>
<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>
if (workbenchCount > 0 && currentWorkbench === "viewer") {
navActions.setWorkbench("fileEditor");
}
await fileActions.addStirlingFileStubs([stub]);
if (workbenchCount === 0) {
navActions.setWorkbench("viewer");
} else {
navActions.setWorkbench("fileEditor");
}
}
},
[
allFileStubs,
state.files.ids,
fileActions,
navActions,
currentWorkbench,
activeFileId,
requestNavigation,
],
);
// 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;
}
{/* Scrollable content */}
<div className="file-sidebar-scroll">
{/* Open from Computer + Google Drive */}
{
<>
<div
className="file-sidebar-action-row"
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>
// 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),
);
{!shouldHideGoogleDrive && (
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);
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
e.target.value = "";
},
[addFiles, navActions],
);
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"
>
<div className="file-sidebar-inner">
{/* Header: hamburger + branding */}
<div
className="file-sidebar-header"
onClick={() => {
if (searchActive) {
handleSearchClose();
} else {
onToggleCollapse?.();
}
}}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()}
aria-label={
collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar")
}
>
{searchActive && !collapsed ? (
<CloseIcon className="file-sidebar-menu-icon" />
) : (
<MenuIcon className="file-sidebar-menu-icon" />
)}
{!collapsed && (
<span className="file-sidebar-brand-text sidebar-content-fade">
Stirling PDF
</span>
)}
</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
}
>
<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-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
onClick={handleGoogleDriveClick}
className="file-sidebar-action-row"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
openFilesModal();
}}
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")
}
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openFilesModal()}
>
<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>
<FolderOpenIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.googleDrive", "Google Drive")}
{t("fileSidebar.openFromComputer", "Open from computer")}
</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.
const thumbnailUrl =
(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}
{!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)" }}
/>
);
})}
</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>
{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.
const thumbnailUrl =
(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
}
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>
{/* 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}
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;
@@ -158,7 +158,10 @@
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));
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 {
@@ -172,7 +175,10 @@
/* 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));
background-image: linear-gradient(
rgba(34, 197, 94, 0.1),
rgba(34, 197, 94, 0.1)
);
}
/* Always show eye for the currently viewed file */
@@ -10,7 +10,11 @@ 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 {
function useLazyThumbnail(
fileId: FileId,
size: number,
thumbnailUrl?: string,
): string | undefined {
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
const attempted = useRef(false);
const indexedDB = useIndexedDB();
@@ -21,7 +25,8 @@ function useLazyThumbnail(fileId: FileId, size: number, thumbnailUrl?: string):
}, [thumbnailUrl]);
useEffect(() => {
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT) return;
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
return;
attempted.current = true;
let cancelled = false;
@@ -57,10 +62,20 @@ export function formatFileDate(lastModifiedTs: number): string {
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());
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();
return lastModified
.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.toLowerCase();
}
if (fileDay.getTime() === yesterday.getTime()) {
return "Yesterday";
@@ -70,10 +85,19 @@ export function formatFileDate(lastModifiedTs: number): string {
if (fileDay >= weekAgo) {
return lastModified.toLocaleDateString("en-US", { weekday: "long" });
}
return lastModified.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return lastModified.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function FilePdfIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
function FilePdfIcon({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<svg
className={className}
@@ -97,7 +121,13 @@ function FilePdfIcon({ className, style }: { className?: string; style?: React.C
);
}
function FileGenericIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
function FileGenericIcon({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<svg
className={className}
@@ -117,7 +147,13 @@ function FileGenericIcon({ className, style }: { className?: string; style?: Rea
);
}
function CheckIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
function CheckIcon({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<svg
className={className}
@@ -179,7 +215,12 @@ export function FileItem({
// 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;
hoverRect && resolvedThumbnail
? {
top: hoverRect.top + hoverRect.height / 2,
left: hoverRect.right + 10,
}
: null;
return (
<>
@@ -216,7 +257,11 @@ export function FileItem({
)}
</div>
<div className="file-sidebar-file-info">
<span className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}>{name}</span>
<span
className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}
>
{name}
</span>
<span className="file-sidebar-file-meta">
{dateLabel}
{dateLabel && typeLabel ? " · " : ""}
@@ -233,16 +278,29 @@ export function FileItem({
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" }} />
<VisibilityOutlinedIcon
className="file-sidebar-eye-open"
sx={{ fontSize: "1.1rem" }}
/>
<VisibilityOffOutlinedIcon
className="file-sidebar-eye-closed"
sx={{ fontSize: "1.1rem" }}
/>
</button>
</div>
{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
className="file-sidebar-thumb-tooltip"
style={{ top: thumbPos.top, left: thumbPos.left }}
>
<img
src={resolvedThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
@@ -108,7 +108,12 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
{...props}
/>
{shouldShowClearButton && (
<button type="button" className={styles.clearButton} onClick={handleClear} aria-label="Clear input">
<button
type="button"
className={styles.clearButton}
onClick={handleClear}
aria-label="Clear input"
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
@@ -73,7 +73,9 @@ export function ViewerInlineControls() {
<ZoomInIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<span className="viewer-inline-controls__zoom-pct">{Math.round(zoomPercent)}%</span>
<span className="viewer-inline-controls__zoom-pct">
{Math.round(zoomPercent)}%
</span>
</div>
);
}
@@ -2,7 +2,11 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRightRail } from "@app/contexts/RightRailContext";
import { useFileState, useFileSelection, useFileActions } from "@app/contexts/FileContext";
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";
@@ -12,7 +16,11 @@ import { WorkbenchType } 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 { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from "@app/types/rightRail";
import {
RightRailButtonConfig,
RightRailRenderContext,
RightRailSection,
} from "@app/types/rightRail";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import FolderIcon from "@mui/icons-material/Folder";
import CloseIcon from "@mui/icons-material/Close";
@@ -33,7 +41,10 @@ interface WorkbenchBarProps {
hasFiles: boolean;
}
function renderWithTooltip(node: React.ReactNode, tooltip: React.ReactNode | undefined) {
function renderWithTooltip(
node: React.ReactNode,
tooltip: React.ReactNode | undefined,
) {
if (!tooltip) return node;
return (
<Tooltip
@@ -48,11 +59,17 @@ function renderWithTooltip(node: React.ReactNode, tooltip: React.ReactNode | und
);
}
export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: WorkbenchBarProps) {
export default function WorkbenchBar({
currentView,
setCurrentView,
hasFiles,
}: WorkbenchBarProps) {
const { t } = useTranslation();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const { pageEditorFunctions, toolPanelMode, leftPanelView } =
useToolWorkflow();
const disableForFullscreen =
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const viewerContext = React.useContext(ViewerContext);
@@ -63,7 +80,8 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
const activeFiles = selectors.getFiles();
const { activeFileId, setActiveFileId } = useViewer();
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
const pageEditorSelectedCount =
pageEditorFunctions?.selectedPageIds?.length ?? 0;
const totalItems = useMemo(() => {
if (currentView === "pageEditor") return pageEditorTotalPages;
@@ -77,7 +95,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
const sectionsWithButtons = useMemo(() => {
return SECTION_ORDER.map((section) => {
const sectionButtons = buttons.filter((btn) => (btn.section ?? "top") === section && (btn.visible ?? true));
const sectionButtons = buttons.filter(
(btn) => (btn.section ?? "top") === section && (btn.visible ?? true),
);
return { section, buttons: sectionButtons };
}).filter((entry) => entry.buttons.length > 0);
}, [buttons]);
@@ -87,9 +107,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
if (currentView === "viewer") {
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
if (!buffer) return;
const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
const fileToExport =
selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
if (!fileToExport) return;
const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined;
const stub = isStirlingFile(fileToExport)
? selectors.getStirlingFileStub(fileToExport.fileId)
: undefined;
try {
const result = await downloadFile({
data: new Blob([buffer], { type: "application/pdf" }),
@@ -113,9 +136,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
return;
}
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
const filesToExport =
selectedFiles.length > 0 ? selectedFiles : activeFiles;
for (const file of filesToExport) {
const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined;
const stub = isStirlingFile(file)
? selectors.getStirlingFileStub(file.fileId)
: undefined;
try {
const result = await downloadFile({
data: file,
@@ -130,11 +156,23 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
});
}
} catch (error) {
console.error("[WorkbenchBar] Failed to export file:", file.name, error);
console.error(
"[WorkbenchBar] Failed to export file:",
file.name,
error,
);
}
}
},
[currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, selectors, fileActions],
[
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
selectors,
fileActions,
],
);
const handlePrint = useCallback(() => {
@@ -146,11 +184,17 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
await fileActions.clearAllFiles();
} else if (currentView === "viewer") {
const file =
(activeFileId ? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) : null) ?? activeFiles[0];
(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 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) {
@@ -164,10 +208,19 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
} else if (currentView === "pageEditor") {
pageEditorFunctions?.closePdf?.();
}
}, [currentView, fileActions, activeFiles, activeFileId, setActiveFileId, pageEditorFunctions, setCurrentView]);
}, [
currentView,
fileActions,
activeFiles,
activeFileId,
setActiveFileId,
pageEditorFunctions,
setCurrentView,
]);
const downloadTooltip = useMemo(() => {
if (currentView === "pageEditor") return t("rightRail.exportAll", "Export PDF");
if (currentView === "pageEditor")
return t("rightRail.exportAll", "Export PDF");
if (currentView === "viewer") return terminology.download;
if (selectedCount > 0) return terminology.downloadSelected;
return terminology.downloadAll;
@@ -176,7 +229,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
const renderButton = useCallback(
(btn: RightRailButtonConfig) => {
const action = actions[btn.id];
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
const disabled = Boolean(
btn.disabled || allButtonsDisabled || disableForFullscreen,
);
const isActive = Boolean(btn.active);
const triggerAction = () => {
@@ -197,7 +252,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
if (!btn.icon) return null;
const ariaLabel = btn.ariaLabel || (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined);
const ariaLabel =
btn.ariaLabel ||
(typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined);
const buttonNode = (
<ActionIcon
variant={isActive ? "filled" : "subtle"}
@@ -219,8 +276,16 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
// 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" /> },
{
value: "viewer",
label: t("workbenchBar.viewer", "Viewer"),
icon: <InsertDriveFileIcon fontSize="small" />,
},
{
value: "fileEditor",
label: t("workbenchBar.activeFiles", "Active Files"),
icon: <FolderIcon fontSize="small" />,
},
];
const barRef = useRef<HTMLDivElement>(null);
@@ -231,14 +296,19 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
const measure = () => {
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
const globalsEl = bar.querySelector<HTMLElement>(".workbench-bar-globals");
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 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
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);
@@ -251,7 +321,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
}, []);
return (
<div ref={barRef} className="workbench-bar" data-wrapped="true" data-tour="workbench-bar">
<div
ref={barRef}
className="workbench-bar"
data-wrapped="true"
data-tour="workbench-bar"
>
{/* Left: View switcher */}
<div className="workbench-bar-views">
{hasFiles &&
@@ -272,20 +347,22 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
{/* 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>
))}
{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>
)}
@@ -299,7 +376,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
radius="md"
className="workbench-bar-action-icon"
onClick={handlePrint}
disabled={totalItems === 0 || allButtonsDisabled || disableForFullscreen}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
aria-label={t("rightRail.print", "Print PDF")}
>
<PrintIcon sx={{ fontSize: "1rem" }} />
@@ -314,14 +393,20 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
radius="md"
className="workbench-bar-action-icon"
onClick={handleClose}
disabled={totalItems === 0 || allButtonsDisabled || disableForFullscreen}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
aria-label={
currentView === "fileEditor" ? t("rightRail.closeAll", "Close All") : t("rightRail.closePdf", "Close PDF")
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"),
currentView === "fileEditor"
? t("rightRail.closeAll", "Close All")
: t("rightRail.closePdf", "Close PDF"),
)}
{/* Download */}
@@ -331,9 +416,15 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
radius="md"
className="workbench-bar-action-icon"
onClick={() => handleExportAll()}
disabled={disableForFullscreen || totalItems === 0 || allButtonsDisabled}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
>
<LocalIcon icon={icons.downloadIconName} width="1rem" height="1rem" />
<LocalIcon
icon={icons.downloadIconName}
width="1rem"
height="1rem"
/>
</ActionIcon>,
downloadTooltip,
)}
@@ -346,9 +437,15 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
radius="md"
className="workbench-bar-action-icon"
onClick={() => handleExportAll(true)}
disabled={disableForFullscreen || totalItems === 0 || allButtonsDisabled}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
>
<LocalIcon icon={icons.saveAsIconName} width="1rem" height="1rem" />
<LocalIcon
icon={icons.saveAsIconName}
width="1rem"
height="1rem"
/>
</ActionIcon>,
t("rightRail.saveAs", "Save As"),
)}
@@ -361,13 +361,22 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
{/* Appearance */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<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")}
{t(
"settings.general.themeDescription",
"Switch between light and dark mode",
)}
</Text>
</div>
<SegmentedControl
@@ -376,18 +385,33 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
if ((themeMode === "dark") !== (val === "dark")) toggleTheme();
}}
data={[
{ label: t("settings.general.themeLight", "Light"), value: "light" },
{ label: t("settings.general.themeDark", "Dark"), value: "dark" },
{
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
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")}
{t(
"settings.general.languageDescription",
"Choose the display language",
)}
</Text>
</div>
<LanguageSelector position="bottom-end" offset={6} />
@@ -207,7 +207,11 @@ export default function ViewerAnnotationControls({
onClick={handleRedactionToggle}
disabled={disabled || currentView !== "viewer"}
>
<LocalIcon icon="scan-delete-rounded" width="1.25rem" height="1.25rem" />
<LocalIcon
icon="scan-delete-rounded"
width="1.25rem"
height="1.25rem"
/>
</ActionIcon>
</Tooltip>
@@ -238,7 +242,11 @@ export default function ViewerAnnotationControls({
aria-pressed={annotationsHidden}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "preview-off-rounded"}
icon={
viewerContext?.isAnnotationsVisible
? "visibility"
: "preview-off-rounded"
}
width="1.25rem"
height="1.25rem"
/>
@@ -42,9 +42,12 @@ const CreateSessionPanel = ({
) : (
<>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">{t("quickAccess.selectedFile", "File")}</div>
<div className="quick-access-popout__label">
{t("quickAccess.selectedFile", "File")}
</div>
<div className="quick-access-popout__row-title">
{selectedFiles[0]?.name || t("quickAccess.noFile", "No file loaded")}
{selectedFiles[0]?.name ||
t("quickAccess.noFile", "No file loaded")}
</div>
</div>
@@ -3,7 +3,10 @@ import { ActionIcon } from "@mantine/core";
import { useRightRail } from "@app/contexts/RightRailContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { RightRailButtonConfig, RightRailRenderContext } from "@app/types/rightRail";
import type {
RightRailButtonConfig,
RightRailRenderContext,
} from "@app/types/rightRail";
/**
* Mini toolbar rendered at the top of the ToolPanel when in viewer mode.
@@ -41,7 +44,9 @@ export function ToolPanelViewerBar() {
if (!btn.icon) return null;
const ariaLabel = btn.ariaLabel || (typeof btn.tooltip === "string" ? btn.tooltip : undefined);
const ariaLabel =
btn.ariaLabel ||
(typeof btn.tooltip === "string" ? btn.tooltip : undefined);
const buttonNode = (
<ActionIcon
variant={isActive ? "filled" : "subtle"}
@@ -59,7 +64,13 @@ export function ToolPanelViewerBar() {
if (!btn.tooltip) return buttonNode;
return (
<Tooltip content={btn.tooltip} position="bottom" offset={6} arrow portalTarget={document.body}>
<Tooltip
content={btn.tooltip}
position="bottom"
offset={6}
arrow
portalTarget={document.body}
>
<div style={{ display: "inline-flex" }}>{buttonNode}</div>
</Tooltip>
);
@@ -75,7 +86,10 @@ export function ToolPanelViewerBar() {
const content = renderButton(btn);
if (!content) return null;
return (
<div key={btn.id} style={{ display: "inline-flex", alignItems: "center" }}>
<div
key={btn.id}
style={{ display: "inline-flex", alignItems: "center" }}
>
{content}
</div>
);
@@ -111,7 +111,12 @@ const CompareDocumentPane = ({
<div className="compare-pane">
<div className="compare-header">
<Group justify="space-between" align="center" wrap="nowrap">
<Text fw={600} size="lg" style={{ minWidth: 0, flex: 1 }} title={title || undefined}>
<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">
@@ -564,8 +564,13 @@
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));
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;
@@ -575,8 +580,16 @@
}
.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));
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 {
@@ -598,18 +611,31 @@
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));
[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: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 {
[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);
}
@@ -165,7 +165,11 @@ const FileStatusIndicator = ({
>
✓{" "}
{selectedFiles.length === 1 ? (
<PrivateContent>{t("fileSelected", "{{filename}}", { filename: selectedFiles[0]?.name })}</PrivateContent>
<PrivateContent>
{t("fileSelected", "{{filename}}", {
filename: selectedFiles[0]?.name,
})}
</PrivateContent>
) : (
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
)}
@@ -58,7 +58,10 @@ const OperationButton = ({
"tool.endpointUnavailable",
"This tool is unavailable on your server.",
),
filesLoading: t("tool.filesLoading", "Files are still loading, please wait."),
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(
@@ -34,7 +34,9 @@ export function ScopedOperationButton({
// 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;
isFilesHydrating && props.disabledReason !== "endpointUnavailable"
? "filesLoading"
: props.disabledReason;
const isViewerMode = workbench === "viewer";
const isFileEditorMode = workbench === "fileEditor";
@@ -62,7 +64,9 @@ export function ScopedOperationButton({
const pendingCount = fileIds.length - allFiles.length;
const isBulkLoading = pendingCount > 1;
const loadProgress = isBulkLoading ? (allFiles.length / fileIds.length) * 100 : 0;
const loadProgress = isBulkLoading
? (allFiles.length / fileIds.length) * 100
: 0;
const showSelectFilesHint =
!disableScopeHints &&
isFileEditorMode &&
@@ -79,7 +83,12 @@ export function ScopedOperationButton({
loading={!isBulkLoading}
variant={props.variant ?? "filled"}
color={props.color ?? "blue"}
style={{ position: "relative", overflow: "hidden", minHeight: "2.5rem", pointerEvents: "none" }}
style={{
position: "relative",
overflow: "hidden",
minHeight: "2.5rem",
pointerEvents: "none",
}}
>
{isBulkLoading && (
<>
@@ -94,17 +103,25 @@ export function ScopedOperationButton({
}}
/>
<Text size="sm" style={{ position: "relative" }}>
{t("tool.filesLoadingProgress", "{{loaded}} / {{total}} files loading...", {
loaded: allFiles.length,
total: fileIds.length,
})}
{t(
"tool.filesLoadingProgress",
"{{loaded}} / {{total}} files loading...",
{
loaded: allFiles.length,
total: fileIds.length,
},
)}
</Text>
</>
)}
</Button>
</Box>
) : (
<OperationButton {...props} submitText={scopedText} disabledReason={effectiveDisabledReason} />
<OperationButton
{...props}
submitText={scopedText}
disabledReason={effectiveDisabledReason}
/>
)}
{viewerFileName && (
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
@@ -201,7 +201,9 @@ const ToolButton: React.FC<ToolButtonProps> = ({
handleUnlessSpecialClick(e, () => handleClick(id));
};
const selectedStyles = isSelected ? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" } : {};
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
@@ -295,7 +297,12 @@ const ToolButton: React.FC<ToolButtonProps> = ({
return (
<div className="tool-button-container">
{star}
<Tooltip content={tooltipContent} position="left" arrow={true} delay={500}>
<Tooltip
content={tooltipContent}
position="left"
arrow={true}
delay={500}
>
{buttonElement}
</Tooltip>
</div>
@@ -94,8 +94,15 @@ const ToolSearch = ({
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={iconOverride ?? (hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />)}
placeholder={
placeholder || t("toolPicker.searchPlaceholder", "Search tools...")
}
icon={
iconOverride ??
(hideIcon ? undefined : (
<LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />
))
}
iconClickable={!!iconOverride}
autoComplete="off"
onFocus={onFocus}
@@ -333,14 +333,19 @@ const EmbedPdfViewerContent = ({
if (previewFile) {
return previewFile;
} else if (activeFiles.length > 0) {
const byId = activeFileId ? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) : null;
const byId = activeFileId
? activeFiles.find(
(f) => isStirlingFile(f) && f.fileId === activeFileId,
)
: null;
return byId || activeFiles[0];
}
return null;
}, [previewFile, activeFiles, activeFileId]);
// Stable id — avoids blob URL churn when FileContext recreates file objects each render.
const currentFileStableId = currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null;
const currentFileStableId =
currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null;
const fileWithUrl = useFileWithUrl(currentFile, currentFileStableId);
// Determine the effective file to display
@@ -765,7 +770,14 @@ const EmbedPdfViewerContent = ({
formApplyInProgressRef.current = false;
}
},
[currentFile, activeFiles, actions, selectors, activeFileIds.length, rotationState.rotation],
[
currentFile,
activeFiles,
actions,
selectors,
activeFileIds.length,
rotationState.rotation,
],
);
useEffect(() => {
@@ -823,7 +835,14 @@ const EmbedPdfViewerContent = ({
layerApplyInProgressRef.current = false;
}
},
[currentFile, activeFiles, actions, selectors, activeFileIds.length, rotationState.rotation],
[
currentFile,
activeFiles,
actions,
selectors,
activeFileIds.length,
rotationState.rotation,
],
);
// Discard pending redactions but save already-applied ones
@@ -1097,7 +1116,11 @@ const EmbedPdfViewerContent = ({
// the effect re-fires before the async fetch completes.
}
if (currentFile && !isCurrentFileEncrypted && (fileChanged || providerChanged)) {
if (
currentFile &&
!isCurrentFileEncrypted &&
(fileChanged || providerChanged)
) {
console.log("[FormFill] Fetching form fields for:", currentFileId);
fetchFormFields(currentFile, currentFileId ?? undefined);
}
@@ -1159,7 +1182,12 @@ const EmbedPdfViewerContent = ({
<Center style={{ flex: 1 }}>
<Stack align="center" gap="md">
<LockIcon style={{ fontSize: 48, opacity: 0.5 }} />
<Text fw={500}>{t("encryptedPdfUnlock.viewerLocked", "This PDF is password-protected")}</Text>
<Text fw={500}>
{t(
"encryptedPdfUnlock.viewerLocked",
"This PDF is password-protected",
)}
</Text>
<Button
variant="filled"
onClick={() => {
@@ -1266,7 +1294,11 @@ const EmbedPdfViewerContent = ({
)}
{/* Thumbnail Sidebar */}
<ThumbnailSidebar visible={isThumbnailSidebarVisible} onToggle={toggleThumbnailSidebar} activeFileId={activeFileId} />
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
activeFileId={activeFileId}
/>
<BookmarkSidebar
visible={isBookmarkSidebarVisible}
thumbnailVisible={isThumbnailSidebarVisible}
@@ -164,7 +164,8 @@ export function LocalEmbedPDF({
// 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);
const fileStableKey =
fileId ?? (file ? `${(file as File).name}-${file.size}` : null);
useEffect(() => {
if (file) {
const objectUrl = URL.createObjectURL(file);
@@ -1,5 +1,12 @@
import { useState, useEffect } from "react";
import { ActionIcon, Button, Paper, Group, NumberInput, Slider } 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";
@@ -325,7 +332,14 @@ export function PdfViewerToolbar({
>
<ZoomInIcon fontSize="small" />
</ActionIcon>
<span style={{ minWidth: "2.5rem", textAlign: "center", fontSize: 12, color: "var(--text-muted)" }}>
<span
style={{
minWidth: "2.5rem",
textAlign: "center",
fontSize: 12,
color: "var(--text-muted)",
}}
>
{displayZoomPercent}%
</span>
</Group>
@@ -9,7 +9,11 @@ interface ThumbnailSidebarProps {
activeFileId?: string | null;
}
export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileId }: ThumbnailSidebarProps) {
export function ThumbnailSidebar({
visible,
onToggle: _onToggle,
activeFileId,
}: ThumbnailSidebarProps) {
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
@@ -19,7 +19,9 @@ const Viewer = (props: ViewerProps) => {
// Determine the active file — previewFile takes priority, then look up by stable ID
const activeFile = useMemo(() => {
if (props.previewFile) return props.previewFile;
const byId = activeFileId ? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) : null;
const byId = activeFileId
? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId)
: null;
return byId ?? activeFiles[0] ?? null;
}, [props.previewFile, activeFiles, activeFileId]);
@@ -201,9 +201,17 @@ export function useViewerRightRailButtons(
},
{
id: "viewer-pan-mode",
icon: <LocalIcon icon="pan-tool-rounded" width="1.25rem" height="1.25rem" />,
tooltip: !isPanning && pendingCount > 0 && redactionActiveType !== null ? applyRedactionsLabel : panLabel,
ariaLabel: !isPanning && pendingCount > 0 && redactionActiveType !== null ? applyRedactionsLabel : panLabel,
icon: (
<LocalIcon icon="pan-tool-rounded" width="1.25rem" height="1.25rem" />
),
tooltip:
!isPanning && pendingCount > 0 && redactionActiveType !== null
? applyRedactionsLabel
: panLabel,
ariaLabel:
!isPanning && pendingCount > 0 && redactionActiveType !== null
? applyRedactionsLabel
: panLabel,
section: "top" as const,
order: 20,
active: isPanning,
@@ -248,7 +256,9 @@ export function useViewerRightRailButtons(
},
{
id: "viewer-rotate-right",
icon: <LocalIcon icon="rotate-right" width="1.25rem" height="1.25rem" />,
icon: (
<LocalIcon icon="rotate-right" width="1.25rem" height="1.25rem" />
),
tooltip: rotateRightLabel,
ariaLabel: rotateRightLabel,
section: "top" as const,
@@ -271,7 +281,13 @@ export function useViewerRightRailButtons(
},
{
id: "viewer-toggle-bookmarks",
icon: <LocalIcon icon="bookmark-add-rounded" width="1.25rem" height="1.25rem" />,
icon: (
<LocalIcon
icon="bookmark-add-rounded"
width="1.25rem"
height="1.25rem"
/>
),
tooltip: bookmarkLabel,
ariaLabel: bookmarkLabel,
section: "top" as const,
@@ -283,7 +299,13 @@ export function useViewerRightRailButtons(
},
{
id: "viewer-toggle-attachments",
icon: <LocalIcon icon="attachment-rounded" width="1.25rem" height="1.25rem" />,
icon: (
<LocalIcon
icon="attachment-rounded"
width="1.25rem"
height="1.25rem"
/>
),
tooltip: attachmentLabel,
ariaLabel: attachmentLabel,
section: "top" as const,
@@ -351,7 +373,11 @@ export function useViewerRightRailButtons(
variant={isReadingAloud ? "filled" : "subtle"}
radius="md"
className="workbench-bar-action-icon"
disabled={disabled || typeof window === "undefined" || !window.speechSynthesis}
disabled={
disabled ||
typeof window === "undefined" ||
!window.speechSynthesis
}
aria-label={readAloudLabel}
onClick={handleReadAloud}
color={isReadingAloud ? "blue" : undefined}
+3 -1
View File
@@ -506,7 +506,9 @@ function FileContextInner({
}
if (failedIds.length > 0) {
const failedNames = failedIds.map((id) => stateRef.current.files.byId[id]?.name ?? id);
const failedNames = failedIds.map(
(id) => stateRef.current.files.byId[id]?.name ?? id,
);
setUnlockError(
t("encryptedPdfUnlock.unlockAllPartialFail", {
defaultValue: "Wrong password for: {{names}}",
@@ -117,9 +117,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
activeFileIds,
maxSelectable = null,
}) => {
const [activeSource, setActiveSource] = useState<"recent" | "local" | "drive">("recent");
const [storageFilter, setStorageFilter] = useState<"all" | "local" | "sharedWithMe" | "sharedByMe">("all");
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>(() => activeFileIds);
const [activeSource, setActiveSource] = useState<
"recent" | "local" | "drive"
>("recent");
const [storageFilter, setStorageFilter] = useState<
"all" | "local" | "sharedWithMe" | "sharedByMe"
>("all");
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>(
() => activeFileIds,
);
const [searchTerm, setSearchTerm] = useState("");
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(
@@ -670,13 +676,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
// Add newly checked files (not already active)
const newlySelected = selectedFiles.filter((f) => !activeFileIds.includes(f.id));
const newlySelected = selectedFiles.filter(
(f) => !activeFileIds.includes(f.id),
);
if (newlySelected.length > 0) {
onRecentFilesSelected(newlySelected);
}
onClose();
}, [selectedFiles, selectedFilesSet, activeFileIds, filteredFiles, removeFiles, onRecentFilesSelected, onClose]);
}, [
selectedFiles,
selectedFilesSet,
activeFileIds,
filteredFiles,
removeFiles,
onRecentFilesSelected,
onClose,
]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
@@ -710,8 +726,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setLastClickedIndex(null);
} else {
// Select all filtered files, capped at maxSelectable
const allIds = filteredFiles.map((file) => file.id).filter(Boolean) as FileId[];
setSelectedFileIds(maxSelectable != null ? allIds.slice(0, maxSelectable) : allIds);
const allIds = filteredFiles
.map((file) => file.id)
.filter(Boolean) as FileId[];
setSelectedFileIds(
maxSelectable != null ? allIds.slice(0, maxSelectable) : allIds,
);
setLastClickedIndex(null);
}
}, [filteredFiles, selectedFileIds, maxSelectable]);
@@ -738,7 +758,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
if (localIds.length > 0) {
try {
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
const safeIds = getSafeFilesToDelete(localIds, allStoredStubs) as FileId[];
const safeIds = getSafeFilesToDelete(
localIds,
allStoredStubs,
) as FileId[];
const safeIdSet = new Set(safeIds);
// Clear UI state in one pass.
@@ -753,7 +776,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
safeIds.forEach((id) => next.delete(id));
for (const [mainId, histFiles] of next.entries()) {
const filtered = histFiles.filter((h) => !safeIdSet.has(h.id));
if (filtered.length !== histFiles.length) next.set(mainId, filtered);
if (filtered.length !== histFiles.length)
next.set(mainId, filtered);
}
return next;
});
@@ -777,7 +801,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
await refreshRecentFiles();
}
}
}, [selectedFileIds, filteredFiles, performFileDelete, getSafeFilesToDelete, removeFiles, refreshRecentFiles, onBulkRemove]);
}, [
selectedFileIds,
filteredFiles,
performFileDelete,
getSafeFilesToDelete,
removeFiles,
refreshRecentFiles,
onBulkRemove,
]);
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
@@ -268,7 +268,15 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
}
closeFilesModal();
},
[addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx, navActions],
[
addFiles,
closeFilesModal,
insertAfterPage,
customHandler,
actions,
fileCtx,
navActions,
],
);
const handleRecentFileSelect = useCallback(
@@ -3,7 +3,13 @@
* Integrates with FileContext to provide transparent file persistence
*/
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
import React, {
createContext,
useContext,
useCallback,
useRef,
useState,
} from "react";
import { fileStorage } from "@app/services/fileStorage";
import { FileId } from "@app/types/file";
import {
+3 -1
View File
@@ -246,7 +246,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// Dep on state.files.ids so the effect re-runs on every add/remove.
useEffect(() => {
if (!activeFileId) return;
const stillInWorkbench = state.files.ids.some((id) => (id as string) === activeFileId);
const stillInWorkbench = state.files.ids.some(
(id) => (id as string) === activeFileId,
);
if (!stillInWorkbench) setActiveFileId(null);
}, [activeFileId, state.files.ids]);
+14 -3
View File
@@ -484,7 +484,11 @@ export async function addFiles(
// Persist the thumbnail to IndexedDB so it's available in future sessions.
// The file was stored before hydration ran, so it had no thumbnail yet.
// Skip blob URLs — they're session-only and won't be valid after reload.
if (primaryThumbnail && enablePersistence && !primaryThumbnail.startsWith("blob:")) {
if (
primaryThumbnail &&
enablePersistence &&
!primaryThumbnail.startsWith("blob:")
) {
try {
await fileStorage.updateThumbnail(fileId, primaryThumbnail);
} catch {
@@ -715,7 +719,11 @@ export async function undoConsumeFiles(
if (indexedDB) {
outputFileIds.forEach((fileId) => {
indexedDB.deleteFile(fileId).catch((error) => {
console.error("📄 undoConsumeFiles: Failed to delete output file from IDB:", fileId, error);
console.error(
"📄 undoConsumeFiles: Failed to delete output file from IDB:",
fileId,
error,
);
// Bump revision so the sidebar re-reads IDB and orphaned files reappear.
indexedDB.bumpRevision?.();
});
@@ -726,7 +734,10 @@ export async function undoConsumeFiles(
await Promise.all(
inputStirlingFileStubs.map((stub) =>
fileStorage.markFileAsLeaf(stub.id).catch((error) => {
console.warn(`📄 undoConsumeFiles: Failed to restore isLeaf for ${stub.id}:`, error);
console.warn(
`📄 undoConsumeFiles: Failed to restore isLeaf for ${stub.id}:`,
error,
);
}),
),
);
@@ -179,7 +179,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
versionStatus: "alpha",
},
multiTool: {
icon: <LocalIcon icon="dashboard-customize-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="dashboard-customize-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.multiTool.title", "Multi-Tool"),
component: null,
workbench: "pageEditor",
@@ -196,7 +202,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: null,
},
merge: {
icon: <LocalIcon icon="library-add-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="library-add-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.merge.title", "Merge"),
component: Merge,
description: t(
@@ -213,7 +225,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
},
// Signing
certSign: {
icon: <LocalIcon icon="workspace-premium-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="workspace-premium-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.certSign.title", "Certificate Sign"),
component: CertSign,
description: t(
@@ -229,7 +247,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: CertSignAutomationSettings,
},
timestampPdf: {
icon: <LocalIcon icon="schedule-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="schedule-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.timestampPdf.title", "Timestamp PDF"),
component: TimestampPdf,
description: t(
@@ -285,7 +309,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
},
addImage: {
icon: <LocalIcon icon="image-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="image-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.addImage.title", "Add Image"),
component: AddImage,
description: t("home.addImage.desc", "Add images anywhere in your PDF"),
@@ -298,7 +328,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
},
annotate: {
icon: <LocalIcon icon="edit-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="edit-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.annotate.title", "Annotate"),
component: Annotate,
description: t(
@@ -358,7 +394,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "watermark"),
},
addStamp: {
icon: <LocalIcon icon="approval-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="approval-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.addStamp.title", "Add Stamp to PDF"),
component: AddStamp,
description: t(
@@ -396,7 +438,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "sanitize"),
},
flatten: {
icon: <LocalIcon icon="layers-clear-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="layers-clear-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.flatten.title", "Flatten"),
component: Flatten,
description: t(
@@ -472,7 +520,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "changePermissions"),
},
getPdfInfo: {
icon: <LocalIcon icon="fact-check-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="fact-check-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
component: GetPdfInfo,
description: t(
@@ -488,7 +542,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
maxFiles: 1,
},
validateSignature: {
icon: <LocalIcon icon="verified-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="verified-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.validateSignature.title", "Validate PDF Signature"),
component: ValidateSignature,
description: t(
@@ -506,7 +566,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
// Document Review
read: {
icon: <LocalIcon icon="article-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="article-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.read.title", "Read"),
component: null,
workbench: "viewer",
@@ -522,7 +588,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: null,
},
changeMetadata: {
icon: <LocalIcon icon="assignment-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="assignment-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.changeMetadata.title", "Change Metadata"),
component: ChangeMetadata,
description: t(
@@ -660,7 +732,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "addPageNumbers"),
},
pageLayout: {
icon: <LocalIcon icon="dashboard-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="dashboard-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.pageLayout.title", "Multi-Page Layout"),
component: PageLayout,
description: t(
@@ -675,7 +753,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "pageLayout"),
},
bookletImposition: {
icon: <LocalIcon icon="menu-book-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="menu-book-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.bookletImposition.title", "Booklet Imposition"),
component: BookletImposition,
operationConfig: bookletImpositionOperationConfig,
@@ -750,7 +834,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
endpoints: ["rearrange-pages"],
},
extractImages: {
icon: <LocalIcon icon="photo-library-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="photo-library-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.extractImages.title", "Extract Images"),
component: ExtractImages,
description: t(
@@ -791,7 +881,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: RemovePagesSettings,
},
removeBlanks: {
icon: <LocalIcon icon="scan-delete-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="scan-delete-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.removeBlanks.title", "Remove Blank Pages"),
component: RemoveBlanks,
description: t(
@@ -937,7 +1033,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
// Advanced Formatting
adjustContrast: {
icon: <LocalIcon icon="palette-outline" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon icon="palette-outline" width="1.5rem" height="1.5rem" />
),
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
component: AdjustContrast,
description: t(
@@ -975,8 +1073,17 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: null,
},
scannerImageSplit: {
icon: <LocalIcon icon="scanner-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.scannerImageSplit.title", "Detect & Split Scanned Photos"),
icon: (
<LocalIcon
icon="scanner-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t(
"home.scannerImageSplit.title",
"Detect & Split Scanned Photos",
),
component: ScannerImageSplit,
description: t(
"home.scannerImageSplit.desc",
@@ -991,7 +1098,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "ScannerImageSplit"),
},
overlayPdfs: {
icon: <LocalIcon icon="layers-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="layers-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
component: OverlayPdfs,
description: t(
@@ -1028,7 +1141,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "replaceColor"),
},
scannerEffect: {
icon: <LocalIcon icon="scanner-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="scanner-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.scannerEffect.title", "Scanner Effect"),
component: null,
description: t(
@@ -1252,7 +1371,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "ocr"),
},
redact: {
icon: <LocalIcon icon="visibility-off-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="visibility-off-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
name: t("home.redact.title", "Redact"),
component: Redact,
description: t(
@@ -15,7 +15,13 @@ export enum ToolType {
* Reason the execute button is disabled. Resolved to a translated tooltip by OperationButton.
* null means the button is enabled.
*/
export type ExecuteDisabledReason = "endpointUnavailable" | "filesLoading" | "noFiles" | "invalidParams" | "viewerMode" | null;
export type ExecuteDisabledReason =
| "endpointUnavailable"
| "filesLoading"
| "noFiles"
| "invalidParams"
| "viewerMode"
| null;
/**
* Result from custom processor with optional metadata about input consumption.
@@ -5,7 +5,9 @@ export function useRightRailTooltipSide(
sidebarRefs?: SidebarRefs,
defaultOffset: number = 16,
): { position: "left" | "right" | "bottom"; offset: number } {
const [position, setPosition] = useState<"left" | "right" | "bottom">("bottom");
const [position, setPosition] = useState<"left" | "right" | "bottom">(
"bottom",
);
useEffect(() => {
const computePosition = () => {
+4 -1
View File
@@ -376,7 +376,10 @@ export default function HomePage() {
<Workbench />
{!hideToolPanel && <ToolPanel />}
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal opened={configModalOpen} onClose={() => setConfigModalOpen(false)} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</Group>
)}
</div>
+8 -2
View File
@@ -340,7 +340,12 @@ class FileStorageService {
if (cursor) {
const record = cursor.value as StoredStirlingFileRecord;
// Only include leaf files (default to true if undefined)
if (record && record.name && typeof record.size === "number" && record.isLeaf !== false) {
if (
record &&
record.name &&
typeof record.size === "number" &&
record.isLeaf !== false
) {
const fresh = this.isThumbnailFresh(record);
if (record.thumbnail) {
if (fresh) tobump.push(record.id);
@@ -408,7 +413,8 @@ class FileStorageService {
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("Transaction aborted"));
transaction.onabort = () =>
reject(transaction.error ?? new Error("Transaction aborted"));
ids.forEach((id) => store.delete(id));
});
}
+126 -30
View File
@@ -2,7 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CompareRoundedIcon from "@mui/icons-material/CompareRounded";
import CloseIcon from "@mui/icons-material/Close";
import { Box, Group, Stack, Text, Button, Modal, ActionIcon } from "@mantine/core";
import {
Box,
Group,
Stack,
Text,
Button,
Modal,
ActionIcon,
} from "@mantine/core";
import SwapVertRoundedIcon from "@mui/icons-material/SwapVertRounded";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
@@ -24,7 +32,10 @@ import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbn
import type { CompareWorkbenchData } from "@app/types/compare";
import { getDefaultWorkbench } from "@app/types/workbench";
import { truncateCenter } from "@app/utils/textUtils";
import { FileSelectorPicker, FileSelectorResult } from "@app/components/shared/FileSelectorPicker";
import {
FileSelectorPicker,
FileSelectorResult,
} from "@app/components/shared/FileSelectorPicker";
import "@app/components/tools/compare/compareView.css";
const CUSTOM_VIEW_ID = "compareWorkbenchView";
@@ -40,10 +51,16 @@ const Compare = (props: BaseToolProps) => {
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const base = useBaseTool("compare", useCompareParameters, useCompareOperation, props, {
minFiles: 0,
ignoreViewerScope: true,
});
const base = useBaseTool(
"compare",
useCompareParameters,
useCompareOperation,
props,
{
minFiles: 0,
ignoreViewerScope: true,
},
);
const operation = base.operation as CompareOperationHook;
const params = base.params.parameters;
@@ -141,7 +158,12 @@ const Compare = (props: BaseToolProps) => {
);
const prepareWorkbenchForRun = useCallback(
(baseId: FileId | null, compId: FileId | null, baseFile: StirlingFile | null, comparisonFile: StirlingFile | null) => {
(
baseId: FileId | null,
compId: FileId | null,
baseFile: StirlingFile | null,
comparisonFile: StirlingFile | null,
) => {
if (!baseId || !compId) return;
updateWorkbenchData({
@@ -184,8 +206,10 @@ const Compare = (props: BaseToolProps) => {
result,
baseFileId,
comparisonFileId,
baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
baseLocalFile:
baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
comparisonLocalFile:
compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
isLoading: false,
});
lastProcessedAtRef.current = processedAt;
@@ -198,8 +222,10 @@ const Compare = (props: BaseToolProps) => {
result: null,
baseFileId,
comparisonFileId,
baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
baseLocalFile:
baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
comparisonLocalFile:
compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
isLoading: true,
});
return;
@@ -219,15 +245,33 @@ const Compare = (props: BaseToolProps) => {
if (!baseSlot || !compSlot) return;
const baseId = baseSlot.stirlingFile.fileId;
const compId = compSlot.stirlingFile.fileId;
const selected: StirlingFile[] = [baseSlot.stirlingFile, compSlot.stirlingFile];
const selected: StirlingFile[] = [
baseSlot.stirlingFile,
compSlot.stirlingFile,
];
prepareWorkbenchForRun(baseId, compId, baseSlot.stirlingFile, compSlot.stirlingFile);
prepareWorkbenchForRun(
baseId,
compId,
baseSlot.stirlingFile,
compSlot.stirlingFile,
);
requestAnimationFrame(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
});
await operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected);
}, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]);
await operation.executeOperation(
{ ...params, baseFileId: baseId, comparisonFileId: compId },
selected,
);
}, [
baseSlot,
compSlot,
navigationActions,
operation,
params,
prepareWorkbenchForRun,
]);
const performSwap = useCallback(() => {
if (!baseSlot || !compSlot) return;
@@ -238,14 +282,32 @@ const Compare = (props: BaseToolProps) => {
if (operation.result) {
const baseId = newBase.stirlingFile.fileId;
const compId = newComp.stirlingFile.fileId;
const selected: StirlingFile[] = [newBase.stirlingFile, newComp.stirlingFile];
prepareWorkbenchForRun(baseId, compId, newBase.stirlingFile, newComp.stirlingFile);
const selected: StirlingFile[] = [
newBase.stirlingFile,
newComp.stirlingFile,
];
prepareWorkbenchForRun(
baseId,
compId,
newBase.stirlingFile,
newComp.stirlingFile,
);
requestAnimationFrame(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
});
void operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected);
void operation.executeOperation(
{ ...params, baseFileId: baseId, comparisonFileId: compId },
selected,
);
}
}, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]);
}, [
baseSlot,
compSlot,
navigationActions,
operation,
params,
prepareWorkbenchForRun,
]);
const handleSwap = useCallback(() => {
if (!baseSlot || !compSlot) return;
@@ -270,7 +332,11 @@ const Compare = (props: BaseToolProps) => {
if (stub) {
const dateMs = (stub.lastModified || stub.createdAt) ?? null;
const dateText = dateMs
? new Date(dateMs).toLocaleDateString(undefined, { month: "short", day: "2-digit", year: "numeric" })
? new Date(dateMs).toLocaleDateString(undefined, {
month: "short",
day: "2-digit",
year: "numeric",
})
: "";
const pageCount = stub.processedFile?.totalPages || null;
@@ -300,14 +366,25 @@ const Compare = (props: BaseToolProps) => {
</ActionIcon>
<Group align="flex-start" wrap="nowrap" gap="md">
<Box style={{ alignSelf: "center" }}>
<DocumentThumbnail file={stub} thumbnail={stub.thumbnailUrl || null} />
<DocumentThumbnail
file={stub}
thumbnail={stub.thumbnailUrl || null}
/>
</Box>
<Stack style={{ minWidth: 0, overflow: "hidden", flex: 1 }}>
<Text fw={600} title={stub.name}>
{truncateCenter(stub.name || "", 50)}
</Text>
{pageCount && dateText && (
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
<Text
size="xs"
c="dimmed"
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{pageCount} {t("compare.pages", "pages")}
<br />
{dateText}
@@ -327,11 +404,17 @@ const Compare = (props: BaseToolProps) => {
isDisabled
? t("compare.edited.selectBaseFirst", "Select original PDF first")
: t(
role === "base" ? "compare.original.placeholder" : "compare.edited.placeholder",
role === "base" ? "Select the original PDF" : "Select the edited PDF",
role === "base"
? "compare.original.placeholder"
: "compare.edited.placeholder",
role === "base"
? "Select the original PDF"
: "Select the edited PDF",
)
}
excludeIds={otherSlot ? [otherSlot.stirlingFile.fileId as string] : []}
excludeIds={
otherSlot ? [otherSlot.stirlingFile.fileId as string] : []
}
disabled={isDisabled}
onSelect={(result: FileSelectorResult) => {
if (role === "base") setBaseSlot(result);
@@ -376,7 +459,9 @@ const Compare = (props: BaseToolProps) => {
styles={{ root: { textDecoration: "underline" } }}
style={{
background: !hasAnySelected ? "transparent" : undefined,
color: !hasAnySelected ? "var(--spdf-clear-disabled-text)" : undefined,
color: !hasAnySelected
? "var(--spdf-clear-disabled-text)"
: undefined,
}}
>
{t("compare.clearSelected", "Clear selected")}
@@ -404,8 +489,13 @@ const Compare = (props: BaseToolProps) => {
disabled={base.operation.isLoading}
aria-label={t("compare.swap.label", "Swap")}
>
<SwapVertRoundedIcon className="compare-step-selection__swap-icon" fontSize="inherit" />
<span className="compare-step-selection__swap-label">{t("compare.swap.label", "Swap")}</span>
<SwapVertRoundedIcon
className="compare-step-selection__swap-icon"
fontSize="inherit"
/>
<span className="compare-step-selection__swap-label">
{t("compare.swap.label", "Swap")}
</span>
</button>
)}
</div>
@@ -452,7 +542,12 @@ const Compare = (props: BaseToolProps) => {
size="sm"
>
<Stack gap="md">
<Text>{t("compare.clear.confirmBody", "This will clear the current file selections.")}</Text>
<Text>
{t(
"compare.clear.confirmBody",
"This will clear the current file selections.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
@@ -482,7 +577,8 @@ const Compare = (props: BaseToolProps) => {
onClick: handleExecuteCompare,
disabled: !canExecute,
// Explicitly null so the noFiles gate is bypassed — Compare manages its own slot state
disabledReason: base.endpointEnabled === false ? "endpointUnavailable" : null,
disabledReason:
base.endpointEnabled === false ? "endpointUnavailable" : null,
testId: "compare-execute",
disableScopeHints: true,
},