mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Restructure frontend code to allow for extensions (#4721)
# Description of Changes Move frontend code into `core` folder and add infrastructure for `proprietary` folder to include premium, non-OSS features
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
import "@app/styles/cookieconsent.css";
|
||||
import "@app/styles/index.css";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ReactNode } from "react";
|
||||
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { FilesModalProvider } from "@app/contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
useScarfTracking();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core application providers
|
||||
* Contains all providers needed for the core
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<OnboardingProvider>
|
||||
<AppConfigProvider>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
{children}
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { Tool } from '@app/types/tool';
|
||||
import MobileLayout from '@app/components/fileManager/MobileLayout';
|
||||
import DesktopLayout from '@app/components/fileManager/DesktopLayout';
|
||||
import DragOverlay from '@app/components/fileManager/DragOverlay';
|
||||
import { FileManagerProvider } from '@app/contexts/FileManagerContext';
|
||||
import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { isGoogleDriveConfigured } from '@app/services/googleDrivePickerService';
|
||||
import { loadScript } from '@app/utils/scriptLoader';
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
}
|
||||
|
||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
if (!selectedTool?.supportedFormats) return true;
|
||||
const extension = fileName.split('.').pop()?.toLowerCase();
|
||||
return selectedTool.supportedFormats.includes(extension || '');
|
||||
}, [selectedTool?.supportedFormats]);
|
||||
|
||||
const refreshRecentFiles = useCallback(async () => {
|
||||
const files = await loadRecentFiles();
|
||||
setRecentFiles(files);
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
|
||||
try {
|
||||
// Use StirlingFileStubs directly - preserves all metadata!
|
||||
onRecentFileSelect(files);
|
||||
} catch (error) {
|
||||
console.error('Failed to process selected files:', error);
|
||||
}
|
||||
}, [onRecentFileSelect]);
|
||||
|
||||
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
||||
onFileUpload(files);
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to process dropped files:', error);
|
||||
}
|
||||
}
|
||||
}, [onFileUpload, refreshRecentFiles]);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
}, [handleRemoveFile, recentFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFilesModalOpen) {
|
||||
refreshRecentFiles();
|
||||
} else {
|
||||
// Reset state when modal is closed
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [isFilesModalOpen, refreshRecentFiles]);
|
||||
|
||||
// Cleanup any blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
||||
// Blob URLs are managed by FileContext and tool operations
|
||||
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Preload Google Drive scripts if configured
|
||||
|
||||
useEffect(() => {
|
||||
if (isGoogleDriveConfigured()) {
|
||||
// Load scripts in parallel without blocking
|
||||
Promise.all([
|
||||
loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
]).catch((error) => {
|
||||
console.warn('Failed to preload Google Drive scripts:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Modal size constants for consistent scaling
|
||||
const modalHeight = '80vh';
|
||||
const modalWidth = isMobile ? '100%' : '80vw';
|
||||
const modalMaxWidth = isMobile ? '100%' : '1200px';
|
||||
const modalMaxHeight = '1200px';
|
||||
const modalMinWidth = isMobile ? '320px' : '800px';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isFilesModalOpen}
|
||||
onClose={closeFilesModal}
|
||||
size={isMobile ? "100%" : "auto"}
|
||||
centered
|
||||
radius="md"
|
||||
className="overflow-hidden p-0"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
|
||||
styles={{
|
||||
content: {
|
||||
position: 'relative',
|
||||
margin: isMobile ? '1rem' : '2rem'
|
||||
},
|
||||
body: { padding: 0 },
|
||||
header: { display: 'none' }
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: modalHeight,
|
||||
width: modalWidth,
|
||||
maxWidth: modalMaxWidth,
|
||||
maxHeight: modalMaxHeight,
|
||||
minWidth: modalMinWidth,
|
||||
margin: '0 auto',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Dropzone
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-file-manager)'
|
||||
}}
|
||||
styles={{
|
||||
inner: { pointerEvents: 'all' }
|
||||
}}
|
||||
>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onRecentFilesSelected={handleRecentFilesSelected}
|
||||
onNewFilesSelect={handleNewFileUpload}
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
isOpen={isFilesModalOpen}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
</FileManagerProvider>
|
||||
</Dropzone>
|
||||
|
||||
<DragOverlay isVisible={isDragging} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileManager;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { Card, Group, Text, Button, Progress } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { StorageStats } from "@app/services/fileStorage";
|
||||
import { formatFileSize } from "@app/utils/fileUtils";
|
||||
import { getStorageUsagePercent } from "@app/utils/storageUtils";
|
||||
|
||||
interface StorageStatsCardProps {
|
||||
storageStats: StorageStats | null;
|
||||
filesCount: number;
|
||||
onClearAll: () => void;
|
||||
onReloadFiles: () => void;
|
||||
}
|
||||
|
||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
storageStats,
|
||||
filesCount,
|
||||
onClearAll,
|
||||
onReloadFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!storageStats) return null;
|
||||
|
||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
||||
|
||||
return (
|
||||
<Card withBorder p="sm" mb="md" style={{ width: "90%", maxWidth: 600 }}>
|
||||
<Group align="center" gap="md">
|
||||
<StorageIcon />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)}
|
||||
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
|
||||
</Text>
|
||||
{storageStats.quota && (
|
||||
<Progress
|
||||
value={storageUsagePercent}
|
||||
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"}
|
||||
size="sm"
|
||||
mt={4}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{filesCount > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={onClearAll}
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
Reload Files
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageStatsCard;
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface PDFAnnotationContextValue {
|
||||
// Drawing mode management
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
|
||||
// Drawing settings
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
|
||||
// History operations
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
|
||||
// Image data management
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
|
||||
// Placement state
|
||||
isPlacementMode: boolean;
|
||||
|
||||
// Signature configuration
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
|
||||
|
||||
interface PDFAnnotationProviderProps {
|
||||
children: ReactNode;
|
||||
// These would come from the signature context
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
isPlacementMode: boolean;
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
children,
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
}) => {
|
||||
const contextValue: PDFAnnotationContextValue = {
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFAnnotationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PDFAnnotationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||
const context = useContext(PDFAnnotationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
enableImageUpload?: boolean;
|
||||
enableTextInput?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
interface BaseAnnotationToolProps {
|
||||
config: AnnotationToolConfig;
|
||||
children: React.ReactNode;
|
||||
onSignatureDataChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
config,
|
||||
children,
|
||||
onSignatureDataChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
activateSignaturePlacementMode,
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
setSignatureData(data);
|
||||
onSignatureDataChange?.(data);
|
||||
};
|
||||
|
||||
const handlePlaceSignature = () => {
|
||||
if (activateSignaturePlacementMode) {
|
||||
activateSignaturePlacementMode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Drawing Controls (Undo/Redo/Place) */}
|
||||
<DrawingControls
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
showPlaceButton={config.showPlaceButton}
|
||||
placeButtonText={config.placeButtonText}
|
||||
/>
|
||||
|
||||
{/* Tool Content */}
|
||||
{React.cloneElement(children as React.ReactElement<any>, {
|
||||
selectedColor,
|
||||
signatureData,
|
||||
onSignatureDataChange: handleSignatureDataChange,
|
||||
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
||||
disabled
|
||||
})}
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||
<Text size="sm">
|
||||
Click anywhere on the PDF to place your annotation.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedColor: string;
|
||||
onColorChange: (color: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title = "Choose Color"
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
swatchesPerRow={6}
|
||||
size="lg"
|
||||
fullWidth
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ColorSwatchButtonProps {
|
||||
color: string;
|
||||
onClick: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
color,
|
||||
onClick,
|
||||
size = 24
|
||||
}) => {
|
||||
return (
|
||||
<ColorSwatch
|
||||
color={color}
|
||||
size={size}
|
||||
radius={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
||||
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
selectedColor: string;
|
||||
penSize: number;
|
||||
penSizeInput: string;
|
||||
onColorSwatchClick: () => void;
|
||||
onPenSizeChange: (size: number) => void;
|
||||
onPenSizeInputChange: (input: string) => void;
|
||||
onSignatureDataChange: (data: string | null) => void;
|
||||
onDrawingComplete?: () => void;
|
||||
disabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
modalWidth?: number;
|
||||
modalHeight?: number;
|
||||
additionalButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
selectedColor,
|
||||
penSize,
|
||||
penSizeInput,
|
||||
onColorSwatchClick,
|
||||
onPenSizeChange,
|
||||
onPenSizeInputChange,
|
||||
onSignatureDataChange,
|
||||
onDrawingComplete,
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
}) => {
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
padRef.current = new SignaturePad(canvas, {
|
||||
penColor: selectedColor,
|
||||
minWidth: penSize * 0.5,
|
||||
maxWidth: penSize * 2.5,
|
||||
throttle: 10,
|
||||
minDistance: 5,
|
||||
velocityFilterWeight: 0.7,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
// Clear pad ref so it reinitializes
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas.toDataURL('image/png');
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
|
||||
|
||||
// Find bounds of non-transparent pixels
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
|
||||
if (alpha > 0) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trimWidth = maxX - minX + 1;
|
||||
const trimHeight = maxY - minY + 1;
|
||||
|
||||
// Create trimmed canvas
|
||||
const trimmedCanvas = document.createElement('canvas');
|
||||
trimmedCanvas.width = trimWidth;
|
||||
trimmedCanvas.height = trimHeight;
|
||||
const trimmedCtx = trimmedCanvas.getContext('2d');
|
||||
if (trimmedCtx) {
|
||||
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
||||
}
|
||||
|
||||
return trimmedCanvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (padRef.current && !padRef.current.isEmpty()) {
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
onSignatureDataChange(trimmedPng);
|
||||
|
||||
// Update preview canvas with proper aspect ratio
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
|
||||
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
previewCanvasRef.current.width / img.width,
|
||||
previewCanvasRef.current.height / img.height
|
||||
);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
|
||||
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = trimmedPng;
|
||||
|
||||
if (onDrawingComplete) {
|
||||
onDrawingComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
if (padRef.current) {
|
||||
padRef.current.clear();
|
||||
}
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
onSignatureDataChange(null);
|
||||
};
|
||||
|
||||
const updatePenColor = (color: string) => {
|
||||
if (padRef.current) {
|
||||
padRef.current.penColor = color;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePenSize = (size: number) => {
|
||||
if (padRef.current) {
|
||||
padRef.current.minWidth = size * 0.8;
|
||||
padRef.current.maxWidth = size * 1.2;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Click to open drawing canvas
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<Popover
|
||||
opened={colorPickerOpen}
|
||||
onChange={setColorPickerOpen}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={() => setColorPickerOpen(!colorPickerOpen)}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={(color) => {
|
||||
onColorSwatchClick();
|
||||
updatePenColor(color);
|
||||
}}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
onValueChange={(size) => {
|
||||
onPenSizeChange(size);
|
||||
updatePenSize(size);
|
||||
}}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref={(el) => {
|
||||
modalCanvasRef.current = el;
|
||||
if (el) initPad(el);
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
display: 'block',
|
||||
touchAction: 'none',
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
height: '400px',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
Clear Canvas
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingCanvas;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Group, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onPlaceSignature?: () => void;
|
||||
hasSignatureData?: boolean;
|
||||
disabled?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlaceSignature,
|
||||
hasSignatureData = false,
|
||||
disabled = false,
|
||||
showPlaceButton = true,
|
||||
placeButtonText = "Update and Place"
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{/* Undo/Redo Controls */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUndo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.undo', 'Undo')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRedo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.redo', 'Redo')}
|
||||
</Button>
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
flex={1}
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { FileInput, Text, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
onImageChange: (file: File | null) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
onImageChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder,
|
||||
hint
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleImageChange = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
// Validate that it's actually an image file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('Selected file is not an image');
|
||||
return;
|
||||
}
|
||||
|
||||
onImageChange(file);
|
||||
} catch (error) {
|
||||
console.error('Error processing image file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
// Clear image data when no file is selected
|
||||
onImageChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label={label || t('sign.image.label', 'Upload signature image')}
|
||||
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{hint || t('sign.image.hint', 'Upload an image of your signature')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
fontFamily: string;
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
text,
|
||||
onTextChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
fontFamily,
|
||||
onFontFamilyChange,
|
||||
textColor = '#000000',
|
||||
onTextColorChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
|
||||
// Sync font size input with prop changes
|
||||
useEffect(() => {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}, [fontSize]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times-Roman', label: 'Times' },
|
||||
{ value: 'Courier', label: 'Courier' },
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
];
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={label || t('sign.text.name', 'Signer Name')}
|
||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label="Font"
|
||||
value={fontFamily}
|
||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||
data={fontOptions}
|
||||
disabled={disabled}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{/* Font Size and Color */}
|
||||
<Group grow>
|
||||
<Combobox
|
||||
onOptionSubmit={(optionValue) => {
|
||||
setFontSizeInput(optionValue);
|
||||
const size = parseInt(optionValue);
|
||||
if (!isNaN(size)) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
fontSizeCombobox.closeDropdown();
|
||||
}}
|
||||
store={fontSizeCombobox}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-200)"
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setFontSizeInput(value);
|
||||
|
||||
// Parse and validate the typed value in real-time
|
||||
const size = parseInt(value);
|
||||
if (!isNaN(size) && size >= 8 && size <= 200) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
|
||||
fontSizeCombobox.openDropdown();
|
||||
fontSizeCombobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => fontSizeCombobox.openDropdown()}
|
||||
onFocus={() => fontSizeCombobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
fontSizeCombobox.closeDropdown();
|
||||
// Clean up invalid values on blur
|
||||
const size = parseInt(fontSizeInput);
|
||||
if (isNaN(size) || size < 8 || size > 200) {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
} else {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{fontSizeOptions.map((size) => (
|
||||
<Combobox.Option value={size} key={size}>
|
||||
{size}px
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
{/* Text Color Picker */}
|
||||
{onTextColorChange && (
|
||||
<Box>
|
||||
<TextInput
|
||||
label="Text Color"
|
||||
value={textColor}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
rightSection={
|
||||
<Box
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: textColor,
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: disabled ? 'default' : 'pointer'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
{onTextColorChange && (
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={textColor}
|
||||
onColorChange={onTextColorChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
||||
import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas';
|
||||
|
||||
interface DrawingToolProps {
|
||||
onDrawingChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||
onDrawingChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [selectedColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
|
||||
const toolConfig = {
|
||||
enableDrawing: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Drawing"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onDrawingChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={onDrawingChange || (() => {})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
||||
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
|
||||
|
||||
interface ImageToolProps {
|
||||
onImageChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
onImageChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [, setImageData] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
setImageData(result);
|
||||
onImageChange?.(result);
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageData(null);
|
||||
onImageChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableImageUpload: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Image"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onImageChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<ImageUploader
|
||||
onImageChange={handleImageUpload}
|
||||
disabled={disabled}
|
||||
label="Upload Image"
|
||||
placeholder="Select image file"
|
||||
hint="Upload a PNG, JPG, or other image file to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
||||
import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont';
|
||||
|
||||
interface TextToolProps {
|
||||
onTextChange?: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TextTool: React.FC<TextToolProps> = ({
|
||||
onTextChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [fontSize, setFontSize] = useState(16);
|
||||
const [fontFamily, setFontFamily] = useState('Helvetica');
|
||||
|
||||
const handleTextChange = (newText: string) => {
|
||||
setText(newText);
|
||||
onTextChange?.(newText);
|
||||
};
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
if (data) {
|
||||
onTextChange?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableTextInput: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Text"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={handleSignatureDataChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInputWithFont
|
||||
text={text}
|
||||
onTextChange={handleTextChange}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={setFontSize}
|
||||
fontFamily={fontFamily}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
disabled={disabled}
|
||||
label="Text Content"
|
||||
placeholder="Enter text to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept,
|
||||
multiple = true
|
||||
}: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleCardClick = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
}
|
||||
// Reset input so same files can be selected again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('fileEditor.addFiles', 'Add files')}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t('fileEditor.addFiles', 'Add Files')}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '100%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFileCard;
|
||||
@@ -0,0 +1,401 @@
|
||||
/* =========================
|
||||
FileEditor Card UI Styles
|
||||
========================= */
|
||||
|
||||
.card {
|
||||
background: var(--file-card-bg);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: visible;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card[data-selected="true"] {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* While dragging */
|
||||
.card.dragging,
|
||||
.card:global(.dragging) {
|
||||
outline: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* -------- Header -------- */
|
||||
.header {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.0625rem 0.0625rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
background: var(--bg-toolbar);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.headerResting {
|
||||
background: #3B4B6E; /* dark blue for unselected in light mode */
|
||||
color: #FFFFFF;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.headerSelected {
|
||||
background: var(--header-selected-bg);
|
||||
color: var(--header-selected-fg);
|
||||
border-bottom: 1px solid var(--header-selected-bg);
|
||||
}
|
||||
|
||||
/* Error highlight (transient) */
|
||||
.headerError {
|
||||
background: var(--color-red-200);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--color-red-500);
|
||||
}
|
||||
|
||||
/* Unsupported (but not errored) header appearance */
|
||||
.headerUnsupported {
|
||||
background: var(--unsupported-bar-bg); /* neutral gray */
|
||||
color: #FFFFFF;
|
||||
border-bottom: 1px solid var(--unsupported-bar-border);
|
||||
}
|
||||
|
||||
/* Selected border color in light mode */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: var(--card-selected-border);
|
||||
}
|
||||
|
||||
/* Reserve space for checkbox instead of logo */
|
||||
.logoMark {
|
||||
margin-left: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.headerIndex {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.headerIconButton {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Menu dropdown */
|
||||
.menuDropdown {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
/* -------- Title / Meta -------- */
|
||||
.title {
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* -------- Preview area -------- */
|
||||
.previewBox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--file-card-bg);
|
||||
}
|
||||
|
||||
.previewPaper {
|
||||
width: 100%;
|
||||
height: calc(100% - 6px);
|
||||
min-height: 9rem;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--file-card-bg);
|
||||
}
|
||||
|
||||
/* Thumbnail fallback */
|
||||
.previewPaper[data-thumb-missing="true"]::after {
|
||||
content: "No preview";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Drag handle grip */
|
||||
.dragHandle {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
color: var(--text-secondary);
|
||||
z-index: 1;
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Actions Overlay */
|
||||
.actionsOverlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 44px; /* just below header */
|
||||
background: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
animation: slideDown 140ms ease-out;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-8px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.actionRow:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.actionDanger {
|
||||
color: var(--text-brand-accent);
|
||||
}
|
||||
|
||||
.actionsDivider {
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Pin indicator */
|
||||
.pinIndicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pinned {
|
||||
color: #FFC107 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Unsupported file indicator */
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
background: #6B7280;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Error pill shown when a file failed processing */
|
||||
.errorPill {
|
||||
margin-left: 1.75rem;
|
||||
background: var(--color-red-500);
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.menuDropdown {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
DARK MODE OVERRIDES
|
||||
========================= */
|
||||
:global([data-mantine-color-scheme="dark"]) .card {
|
||||
outline-color: #3A4047; /* deselected stroke */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
|
||||
outline-color: #4B525A; /* selected stroke (subtle grey) */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .headerResting {
|
||||
background: #1F2329; /* requested default unselected color */
|
||||
color: var(--tool-header-text); /* #D0D6DC */
|
||||
border-bottom-color: var(--tool-header-border); /* #3A4047 */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .headerSelected {
|
||||
background: var(--tool-header-border); /* #3A4047 */
|
||||
color: var(--tool-header-text); /* #D0D6DC */
|
||||
border-bottom-color: var(--tool-header-border);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .title {
|
||||
color: #D0D6DC; /* title text */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .meta {
|
||||
color: #6B7280; /* subtitle text */
|
||||
}
|
||||
|
||||
/* Light mode selected header stroke override */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: #3B4B6E;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Add File Card Styles
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
background: var(--file-card-bg);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.addFileCard:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-blue-500);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.addFileCard:focus {
|
||||
outline: 2px solid var(--color-blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.addFileHeader {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileHeader {
|
||||
background: var(--color-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.addFileContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.addFileIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-subtle);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileIcon {
|
||||
background: var(--color-blue-50);
|
||||
}
|
||||
|
||||
.addFileText {
|
||||
font-weight: 500;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addFileSubtext {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Text, Center, Box, LoadingOverlay, Stack
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
||||
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
||||
import AddFileCard from '@app/components/fileEditor/AddFileCard';
|
||||
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
||||
import { FileId, StirlingFile } from '@app/types/fileContext';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { downloadBlob } from '@app/utils/downloadUtils';
|
||||
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: () => void;
|
||||
onMergeFiles?: (files: StirlingFile[]) => void;
|
||||
toolMode?: boolean;
|
||||
supportedExtensions?: string[];
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
toolMode = false,
|
||||
supportedExtensions = ["pdf"]
|
||||
}: FileEditorProps) => {
|
||||
|
||||
// Utility function to check if a file extension is supported
|
||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
||||
const extension = detectFileExtension(fileName);
|
||||
return extension ? supportedExtensions.includes(extension) : false;
|
||||
}, [supportedExtensions]);
|
||||
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: fileContextActions } = useFileContext();
|
||||
const { clearAllFileErrors } = fileContextActions;
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const totalItems = state.files.ids.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
// Toast helpers
|
||||
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
|
||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||
}, []);
|
||||
const showError = useCallback((message: string) => {
|
||||
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
// Create refs for frequently changing values to stabilize callbacks
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
contextSelectedIdsRef.current = contextSelectedIds;
|
||||
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
const handleSelectAllFiles = useCallback(() => {
|
||||
setSelectedFiles(state.files.ids);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on select all:', error);
|
||||
}
|
||||
}
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleDeselectAllFiles = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on deselect:', error);
|
||||
}
|
||||
}
|
||||
}, [setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleCloseSelectedFiles = useCallback(() => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
void removeFiles(selectedFileIds, false);
|
||||
setSelectedFiles([]);
|
||||
}, [selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll: handleSelectAllFiles,
|
||||
onDeselectAll: handleDeselectAllFiles,
|
||||
onCloseSelected: handleCloseSelectedFiles,
|
||||
});
|
||||
|
||||
// Process uploaded files using context
|
||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
if (uploadedFiles.length > 0) {
|
||||
// FileContext will automatically handle ZIP extraction based on user preferences
|
||||
// - Respects autoUnzip setting
|
||||
// - Respects autoUnzipFileLimit
|
||||
// - HTML ZIPs stay intact
|
||||
// - Non-ZIP files pass through unchanged
|
||||
await addFiles(uploadedFiles, { selectFiles: true });
|
||||
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
}
|
||||
}, [addFiles, showStatus, showError]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: FileId[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
|
||||
} else {
|
||||
// Add file to selection
|
||||
// In tool mode, typically allow multiple files unless specified otherwise
|
||||
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// Check if we've hit the selection limit
|
||||
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
|
||||
return;
|
||||
}
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
|
||||
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||
const currentIds = activeStirlingFileStubs.map(r => r.id);
|
||||
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||
const targetIndex = currentIds.findIndex(id => id === targetFileId);
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
console.warn('Could not find source or target file for reordering');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multi-file selection reordering
|
||||
const filesToMove = selectedFileIds.length > 1
|
||||
? selectedFileIds.filter(id => currentIds.includes(id))
|
||||
: [sourceFileId];
|
||||
|
||||
// Create new order
|
||||
const newOrder = [...currentIds];
|
||||
|
||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
|
||||
.sort((a, b) => b - a); // Sort descending
|
||||
|
||||
sourceIndices.forEach(index => {
|
||||
newOrder.splice(index, 1);
|
||||
});
|
||||
|
||||
// Calculate insertion index after removals
|
||||
let insertIndex = newOrder.findIndex(id => id === targetFileId);
|
||||
if (insertIndex !== -1) {
|
||||
// Determine if moving forward or backward
|
||||
const isMovingForward = sourceIndex < targetIndex;
|
||||
if (isMovingForward) {
|
||||
// Moving forward: insert after target
|
||||
insertIndex += 1;
|
||||
} else {
|
||||
// Moving backward: insert before target (insertIndex already correct)
|
||||
}
|
||||
} else {
|
||||
// Target was moved, insert at end
|
||||
insertIndex = newOrder.length;
|
||||
}
|
||||
|
||||
// Insert files at the calculated position
|
||||
newOrder.splice(insertIndex, 0, ...filesToMove);
|
||||
|
||||
// Update file order
|
||||
reorderFiles(newOrder);
|
||||
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
|
||||
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleCloseFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
const contextFileId = record.id;
|
||||
removeFiles([contextFileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleDownloadFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
try {
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
await fileActions.addStirlingFileStubs(result.extractedStubs);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Failed to extract files from ${file.name}`,
|
||||
body: result.errors.join('\n'),
|
||||
expandable: true,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unzip file:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Error unzipping ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
setSelectedFiles([fileId]);
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
// Use FileContext to handle loading stored files
|
||||
// The files are already in FileContext, just need to add them to active files
|
||||
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
showError('Failed to load some files from storage');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
activateOnClick={false}
|
||||
activateOnDrag={true}
|
||||
>
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md">
|
||||
|
||||
|
||||
{activeStirlingFileStubs.length === 0 ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
rowGap: '1.5rem',
|
||||
padding: '1rem',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
@@ -0,0 +1,447 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
import { useFileContext } from '@app/contexts/FileContext';
|
||||
import { useFileState } from '@app/contexts/file/fileHooks';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { formatFileSize } from '@app/utils/fileUtils';
|
||||
import ToolChain from '@app/components/shared/ToolChain';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
|
||||
|
||||
|
||||
interface FileEditorThumbnailProps {
|
||||
file: StirlingFileStub;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
onUnzipFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
|
||||
const FileEditorThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onCloseFile,
|
||||
onViewFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
|
||||
// ---- Meta formatting ----
|
||||
const prettySize = useMemo(() => {
|
||||
return formatFileSize(file.size);
|
||||
}, [file.size]);
|
||||
|
||||
const extUpper = useMemo(() => {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
||||
return (m?.[1] || '').toUpperCase();
|
||||
}, [file.name]);
|
||||
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
pageCount > 0
|
||||
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
|
||||
: '',
|
||||
[pageCount]
|
||||
);
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
const d = new Date(file.lastModified);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(d);
|
||||
}, [file.lastModified]);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
}
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id
|
||||
}),
|
||||
canDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||
|
||||
// Handle close with confirmation
|
||||
const handleCloseWithConfirmation = useCallback(() => {
|
||||
setShowCloseModal(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, onCloseFile]);
|
||||
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseModal(false);
|
||||
}, []);
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
id: 'view',
|
||||
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
||||
label: t('openInViewer', 'Open in Viewer'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
label: t('download', 'Download'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'unzip',
|
||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||
label: t('fileManager.unzip', 'Unzip'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (onUnzipFile) {
|
||||
onUnzipFile(file.id);
|
||||
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile,
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
||||
label: t('close', 'Close'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseWithConfirmation();
|
||||
},
|
||||
color: 'red',
|
||||
}
|
||||
], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
if (!isSupported) return;
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
if (!isSupported) return styles.headerUnsupported;
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileElementRef}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-tour="file-card-checkbox"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{opacity: isDragging ? 0.9 : 1}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setShowHoverMenu(true)}
|
||||
onMouseLeave={() => setShowHoverMenu(false)}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t('error._value', 'Error')}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
color="var(--checkbox-checked-bg)"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>
|
||||
{t('unsupported', 'Unsupported')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
|
||||
<ActionIcon
|
||||
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
data-tour="file-card-pin"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
textAlign: 'center',
|
||||
background: 'var(--file-card-bg)',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
className={styles.meta}
|
||||
lineClamp={3}
|
||||
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
||||
>
|
||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{`v${file.versionNumber} - `}
|
||||
{dateLabel}
|
||||
{extUpper ? ` - ${extUpper} file` : ''}
|
||||
{pageLabel ? ` - ${pageLabel}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
img.style.display = 'none';
|
||||
img.parentElement?.setAttribute('data-thumb-missing', 'true');
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
maxHeight: '80%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 0,
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--border-default)',
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
alignSelf: 'start'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
|
||||
{/* Tool chain display at bottom */}
|
||||
{file.toolHistory && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '4px',
|
||||
left: '4px',
|
||||
right: '4px',
|
||||
padding: '4px 6px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth={'100%'}
|
||||
color='var(--mantine-color-gray-7)'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu
|
||||
show={showHoverMenu || isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
/>
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
opened={showCloseModal}
|
||||
onClose={handleCancelClose}
|
||||
title={t('confirmClose', 'Confirm Close')}
|
||||
centered
|
||||
size="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseConfirm', 'Close File')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FileEditorThumbnail);
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface FileEditorRightRailButtonsParams {
|
||||
totalItems: number;
|
||||
selectedCount: number;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onCloseSelected: () => void;
|
||||
}
|
||||
|
||||
export function useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onCloseSelected,
|
||||
}: FileEditorRightRailButtonsParams) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'file-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.selectAll', 'Select All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
visible: totalItems > 0,
|
||||
onClick: onSelectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-deselect-all',
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.deselectAll', 'Deselect All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onDeselectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-close-selected',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onCloseSelected,
|
||||
},
|
||||
], [t, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize } from '@app/utils/fileUtils';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
|
||||
interface CompactFileDetailsProps {
|
||||
currentFile: StirlingFileStub | null;
|
||||
thumbnail: string | null;
|
||||
selectedFiles: StirlingFileStub[];
|
||||
currentFileIndex: number;
|
||||
numberOfFiles: number;
|
||||
isAnimating: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onOpenFiles: () => void;
|
||||
}
|
||||
|
||||
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
currentFile,
|
||||
thumbnail,
|
||||
selectedFiles,
|
||||
currentFileIndex,
|
||||
numberOfFiles,
|
||||
isAnimating,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onOpenFiles
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
const hasMultipleFiles = numberOfFiles > 1;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
{/* Compact mobile layout */}
|
||||
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||
{/* Small preview */}
|
||||
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{currentFile && thumbnail ? (
|
||||
<img
|
||||
className='ph-no-capture'
|
||||
src={thumbnail}
|
||||
alt={currentFile.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
) : currentFile ? (
|
||||
<Center style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 4
|
||||
}}>
|
||||
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
</Center>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text className='ph-no-capture' size="sm" fw={500} truncate>
|
||||
{currentFile ? currentFile.name : 'No file selected'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ''}
|
||||
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||
</Text>
|
||||
{hasMultipleFiles && (
|
||||
<Text size="xs" c="blue">
|
||||
{currentFileIndex + 1} of {selectedFiles.length}
|
||||
</Text>
|
||||
)}
|
||||
{/* Compact tool chain for mobile */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation arrows for multiple files */}
|
||||
{hasMultipleFiles && (
|
||||
<Box style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={isAnimating}
|
||||
>
|
||||
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={isAnimating}
|
||||
>
|
||||
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||
: t('fileManager.openFile', 'Open File')
|
||||
}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactFileDetails;
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Grid } from '@mantine/core';
|
||||
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
|
||||
import FileDetails from '@app/components/fileManager/FileDetails';
|
||||
import SearchInput from '@app/components/fileManager/SearchInput';
|
||||
import FileListArea from '@app/components/fileManager/FileListArea';
|
||||
import FileActions from '@app/components/fileManager/FileActions';
|
||||
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
|
||||
const DesktopLayout: React.FC = () => {
|
||||
const {
|
||||
activeSource,
|
||||
recentFiles,
|
||||
modalHeight,
|
||||
} = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
{/* Column 1: File Sources */}
|
||||
<Grid.Col span="content" p="lg" style={{
|
||||
minWidth: '13.625rem',
|
||||
width: '13.625rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
}} data-tour="file-sources">
|
||||
<FileSourceButtons />
|
||||
</Grid.Col>
|
||||
|
||||
{/* Column 2: File List */}
|
||||
<Grid.Col span="auto" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
flex: '1 1 0px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'var(--bg-file-list)',
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{activeSource === 'recent' && (
|
||||
<>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<FileActions />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<FileListArea
|
||||
scrollAreaHeight={`calc(${modalHeight} )`}
|
||||
scrollAreaStyle={{
|
||||
height: activeSource === 'recent' && recentFiles.length > 0 ? modalHeight : '100%',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Column 3: File Details */}
|
||||
<Grid.Col p="xl" span="content" style={{
|
||||
minWidth: '25rem',
|
||||
width: '25rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
maxWidth: '18rem'
|
||||
}}>
|
||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||
<FileDetails />
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Hidden file input for local file selection */}
|
||||
<HiddenFileInput />
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopLayout;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, useMantineTheme, alpha } from '@mantine/core';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DragOverlayProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: alpha(theme.colors.blue[6], 0.1),
|
||||
border: `0.125rem dashed ${theme.colors.blue[6]}`,
|
||||
borderRadius: '1.875rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="md">
|
||||
<UploadFileIcon style={{ fontSize: '4rem', color: theme.colors.blue[6] }} />
|
||||
<Text size="xl" fw={500} c="blue.6">
|
||||
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragOverlay;
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
const EmptyFilesState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { onLocalFileClick } = useFileManagerContext();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
onLocalFileClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}
|
||||
>
|
||||
{/* Container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '3rem 2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
minWidth: '20rem',
|
||||
maxWidth: '28rem',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{/* No Recent Files Message */}
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center" size="lg">
|
||||
{t('fileManager.noRecentFiles', 'No recent files')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Stirling PDF Logo */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-file-manager)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: isUploadHover ? '2rem' : '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? '100%' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
|
||||
}}
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyFilesState;
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
|
||||
const FileActions: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
|
||||
useFileManagerContext();
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onSelectAll();
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (selectedFileIds.length > 0) {
|
||||
onDeleteSelected();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
if (selectedFileIds.length > 0) {
|
||||
onDownloadSelected();
|
||||
}
|
||||
};
|
||||
|
||||
// Only show actions if there are files
|
||||
if (recentFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||
const hasSelection = selectedFileIds.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
minHeight: "3rem",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Left: Select All */}
|
||||
<div>
|
||||
<Tooltip
|
||||
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleSelectAll}
|
||||
disabled={filteredFiles.length === 0}
|
||||
radius="sm"
|
||||
>
|
||||
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Center: Selected count */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{hasSelection && (
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Delete and Download */}
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={!hasSelection}
|
||||
radius="sm"
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("fileManager.downloadSelected", "Download Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleDownloadSelected}
|
||||
disabled={!hasSelection}
|
||||
radius="sm"
|
||||
>
|
||||
<DownloadIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileActions;
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Stack, Button, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIndexedDBThumbnail } from '@app/hooks/useIndexedDBThumbnail';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import FilePreview from '@app/components/shared/FilePreview';
|
||||
import FileInfoCard from '@app/components/fileManager/FileInfoCard';
|
||||
import CompactFileDetails from '@app/components/fileManager/CompactFileDetails';
|
||||
|
||||
interface FileDetailsProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
compact = false
|
||||
}) => {
|
||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Get the currently displayed file
|
||||
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
|
||||
// Use IndexedDB hook for the current file
|
||||
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
||||
|
||||
// Get thumbnail for current file
|
||||
const getCurrentThumbnail = () => {
|
||||
return currentThumbnail;
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Reset index when selection changes
|
||||
useEffect(() => {
|
||||
if (currentFileIndex >= selectedFiles.length) {
|
||||
setCurrentFileIndex(0);
|
||||
}
|
||||
}, [selectedFiles.length, currentFileIndex]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<CompactFileDetails
|
||||
currentFile={currentFile}
|
||||
thumbnail={getCurrentThumbnail()}
|
||||
selectedFiles={selectedFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
numberOfFiles={selectedFiles.length}
|
||||
isAnimating={isAnimating}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onOpenFiles={onOpenFiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
||||
{/* Section 1: Thumbnail Preview */}
|
||||
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.5 - 2rem)`, textAlign: 'center', padding: 'xs' }}>
|
||||
<FilePreview
|
||||
file={currentFile}
|
||||
thumbnail={getCurrentThumbnail()}
|
||||
showStacking={true}
|
||||
showNavigation={true}
|
||||
totalFiles={selectedFiles.length}
|
||||
isAnimating={isAnimating}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Section 2: File Details */}
|
||||
<FileInfoCard
|
||||
currentFile={currentFile}
|
||||
modalHeight={modalHeight}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
mb="xl"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection}
|
||||
fullWidth
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||
: t('fileManager.openFile', 'Open File')
|
||||
}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDetails;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, Collapse, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import FileListItem from '@app/components/fileManager/FileListItem';
|
||||
|
||||
interface FileHistoryGroupProps {
|
||||
leafFile: StirlingFileStub;
|
||||
historyFiles: StirlingFileStub[];
|
||||
isExpanded: boolean;
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
leafFile,
|
||||
historyFiles,
|
||||
isExpanded,
|
||||
onDownloadSingle,
|
||||
onFileDoubleClick,
|
||||
onHistoryFileRemove,
|
||||
isFileSupported,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Sort history files by version number (oldest first, excluding the current leaf file)
|
||||
const sortedHistory = historyFiles
|
||||
.filter(file => file.id !== leafFile.id) // Exclude the leaf file itself
|
||||
.sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1));
|
||||
|
||||
if (!isExpanded || sortedHistory.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse in={isExpanded}>
|
||||
<Box ml="md" mt="xs" mb="sm">
|
||||
<Group align="center" mb="sm">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t('fileManager.fileHistory', 'File History')} ({sortedHistory.length})
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box ml="md">
|
||||
{sortedHistory.map((historyFile) => (
|
||||
<FileListItem
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
isSelected={false} // History files are not selectable
|
||||
isSupported={isFileSupported(historyFile.name)}
|
||||
onSelect={() => {}} // No selection for history files
|
||||
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
|
||||
onDownload={() => onDownloadSingle(historyFile)}
|
||||
onDoubleClick={() => onFileDoubleClick(historyFile)}
|
||||
isHistoryFile={true} // This enables "Add to Recents" in menu
|
||||
isLatestVersion={false} // History files are never latest
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileHistoryGroup;
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import ToolChain from '@app/components/shared/ToolChain';
|
||||
|
||||
interface FileInfoCardProps {
|
||||
currentFile: StirlingFileStub | null;
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
currentFile,
|
||||
modalHeight
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
||||
<Text size="sm" fw={500} ta="center" c="white">
|
||||
{t('fileManager.details', 'File Details')}
|
||||
</Text>
|
||||
</Box>
|
||||
<ScrollArea style={{ flex: 1 }} p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
||||
{currentFile ? currentFile.name : ''}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
|
||||
{currentFile ? (
|
||||
<Badge size="sm" variant="light">
|
||||
{detectFileExtension(currentFile.name).toUpperCase()}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" fw={500}></Text>
|
||||
)}
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentFile ? getFileSize(currentFile) : ''}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last Modified')}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||
{currentFile &&
|
||||
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
|
||||
v{currentFile ? (currentFile.versionNumber || 1) : ''}
|
||||
</Badge>}
|
||||
|
||||
</Group>
|
||||
|
||||
{/* Tool Chain Display */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box py="xs">
|
||||
<Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text>
|
||||
<ToolChain
|
||||
toolChain={currentFile.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInfoCard;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileListItem from '@app/components/fileManager/FileListItem';
|
||||
import FileHistoryGroup from '@app/components/fileManager/FileHistoryGroup';
|
||||
import EmptyFilesState from '@app/components/fileManager/EmptyFilesState';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
scrollAreaHeight: string;
|
||||
scrollAreaStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollAreaHeight,
|
||||
scrollAreaStyle = {},
|
||||
}) => {
|
||||
const {
|
||||
activeSource,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFilesSet,
|
||||
expandedFileIds,
|
||||
loadedHistoryFiles,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onHistoryFileRemove,
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (activeSource === 'recent') {
|
||||
return (
|
||||
<ScrollArea
|
||||
h={scrollAreaHeight}
|
||||
style={{
|
||||
...scrollAreaStyle
|
||||
}}
|
||||
type="always"
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
<Center style={{ height: '12.5rem' }}>
|
||||
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
// All files in filteredFiles are now leaf files only
|
||||
const historyFiles = loadedHistoryFiles.get(file.id) || [];
|
||||
const isExpanded = expandedFileIds.has(file.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={file.id}>
|
||||
<FileListItem
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
isHistoryFile={false} // All files here are leaf files
|
||||
isLatestVersion={true} // All files here are the latest versions
|
||||
/>
|
||||
|
||||
<FileHistoryGroup
|
||||
leafFile={file}
|
||||
historyFiles={historyFiles}
|
||||
isExpanded={isExpanded}
|
||||
onDownloadSingle={onDownloadSingle}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
onHistoryFileRemove={onHistoryFileRemove}
|
||||
isFileSupported={isFileSupported}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// Google Drive placeholder
|
||||
return (
|
||||
<Center style={{ height: '12.5rem' }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<CloudIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center">{t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileListArea;
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '@app/utils/fileUtils';
|
||||
import { FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import ToolChain from '@app/components/shared/ToolChain';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
isSelected: boolean;
|
||||
isSupported: boolean;
|
||||
onSelect: (shiftKey?: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onDownload?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
isLast?: boolean;
|
||||
isHistoryFile?: boolean; // Whether this is a history file (indented)
|
||||
isLatestVersion?: boolean; // Whether this is the latest version (shows chevron)
|
||||
}
|
||||
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
file,
|
||||
isSelected,
|
||||
isSupported,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onDoubleClick,
|
||||
isHistoryFile = false,
|
||||
isLatestVersion = false
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
|
||||
// Get version information for this file
|
||||
const leafFileId = (isLatestVersion ? file.id : (file.originalFileId || file.id)) as FileId;
|
||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||
const isExpanded = expandedFileIds.has(leafFileId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: isHistoryFile ? 'default' : 'pointer',
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-gray-1)'
|
||||
: (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
transition: 'background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none',
|
||||
paddingLeft: isHistoryFile ? '2rem' : '0.75rem', // Indent history files
|
||||
borderLeft: isHistoryFile ? '3px solid var(--mantine-color-blue-4)' : 'none' // Visual indicator for history
|
||||
}}
|
||||
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
{!isHistoryFile && (
|
||||
<Box>
|
||||
{/* Checkbox for regular files only */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
styles={{
|
||||
input: {
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
</Group>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{getFileSize(file)} • {getFileDate(file)}
|
||||
</Text>
|
||||
|
||||
{/* Tool chain for processed files */}
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
maxWidth={'150px'}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Three dots menu - fades in/out on hover */}
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
opacity: shouldShowHovered ? 1 : 0,
|
||||
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
|
||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||
pointerEvents: shouldShowHovered ? 'auto' : 'none'
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onDownload && (
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload();
|
||||
}}
|
||||
>
|
||||
{t('fileManager.download', 'Download')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{/* Show/Hide History option for latest version files */}
|
||||
{isLatestVersion && hasVersionHistory && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<HistoryIcon style={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpansion(leafFileId);
|
||||
}}
|
||||
>
|
||||
{
|
||||
(isExpanded ?
|
||||
t('fileManager.hideHistory', 'Hide History') :
|
||||
t('fileManager.showHistory', 'Show History')
|
||||
)
|
||||
}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Restore option for history files */}
|
||||
{isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{t('fileManager.restore', 'Restore')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unzip option for ZIP files */}
|
||||
{isZipFile && !isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnzipFile(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.unzip', 'Unzip')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t('fileManager.delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Box>
|
||||
{ <Divider color="var(--mantine-color-gray-3)" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileListItem;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import UploadIcon from '@mui/icons-material/Upload';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
const files = await openGoogleDrivePicker({ multiple: true });
|
||||
if (files.length > 0) {
|
||||
onGoogleDriveSelect(files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick files from Google Drive:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
|
||||
getStyles: (source: string) => ({
|
||||
root: {
|
||||
backgroundColor: activeSource === source ? undefined : 'transparent',
|
||||
color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)'
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const buttons = (
|
||||
<>
|
||||
<Button
|
||||
leftSection={<HistoryIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={() => onSourceChange('recent')}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
color={buttonProps.getColor('recent')}
|
||||
styles={buttonProps.getStyles('recent')}
|
||||
>
|
||||
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<UploadIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={onLocalFileClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{horizontal ? t('fileUpload.uploadFiles', 'Upload') : t('fileUpload.uploadFiles', 'Upload Files')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<CloudIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled={!isGoogleDriveEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (horizontal) {
|
||||
return (
|
||||
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||
{buttons}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||
{t('fileManager.myFiles', 'My Files')}
|
||||
</Text>
|
||||
{buttons}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSourceButtons;
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
|
||||
const HiddenFileInput: React.FC = () => {
|
||||
const { fileInputRef, onFileInputChange } = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={true}
|
||||
onChange={onFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HiddenFileInput;
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
|
||||
import FileDetails from '@app/components/fileManager/FileDetails';
|
||||
import SearchInput from '@app/components/fileManager/SearchInput';
|
||||
import FileListArea from '@app/components/fileManager/FileListArea';
|
||||
import FileActions from '@app/components/fileManager/FileActions';
|
||||
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
|
||||
const MobileLayout: React.FC = () => {
|
||||
const {
|
||||
activeSource,
|
||||
selectedFiles,
|
||||
modalHeight,
|
||||
} = useFileManagerContext();
|
||||
|
||||
// Calculate the height more accurately based on actual content
|
||||
const calculateFileListHeight = () => {
|
||||
// Base modal height minus padding and gaps
|
||||
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
||||
|
||||
// Estimate heights of fixed components
|
||||
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
||||
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
|
||||
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
|
||||
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
|
||||
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
|
||||
|
||||
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box h="100%" p="sm" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{/* Section 1: File Sources - Fixed at top */}
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileSourceButtons horizontal={true} />
|
||||
</Box>
|
||||
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileDetails compact={true} />
|
||||
</Box>
|
||||
|
||||
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
||||
<Box style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'var(--bg-file-list)',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0
|
||||
}}>
|
||||
{activeSource === 'recent' && (
|
||||
<>
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
<SearchInput />
|
||||
</Box>
|
||||
<Box style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||
}}>
|
||||
<FileActions />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<FileListArea
|
||||
scrollAreaHeight={calculateFileListHeight()}
|
||||
scrollAreaStyle={{
|
||||
height: calculateFileListHeight(),
|
||||
maxHeight: '60vh',
|
||||
minHeight: '9.375rem',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 0
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Hidden file input for local file selection */}
|
||||
<HiddenFileInput />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileLayout;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { TextInput } from '@mantine/core';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
|
||||
interface SearchInputProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const SearchInput: React.FC<SearchInputProps> = ({ style }) => {
|
||||
const { t } = useTranslation();
|
||||
const { searchTerm, onSearchChange } = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
placeholder={t('fileManager.searchFiles', 'Search files...')}
|
||||
leftSection={<SearchIcon />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
|
||||
style={{ padding: '0.5rem', ...style }}
|
||||
styles={{
|
||||
input: {
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { HotkeyBinding } from '@app/utils/hotkeys';
|
||||
import { useHotkeys } from '@app/contexts/HotkeyContext';
|
||||
|
||||
interface HotkeyDisplayProps {
|
||||
binding: HotkeyBinding | null | undefined;
|
||||
size?: 'sm' | 'md';
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const baseKeyStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'var(--mantine-color-gray-1)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '0.125rem 0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1,
|
||||
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
|
||||
minWidth: '1.35rem',
|
||||
color: 'var(--mantine-color-text)',
|
||||
};
|
||||
|
||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
|
||||
const { getDisplayParts } = useHotkeys();
|
||||
const parts = getDisplayParts(binding);
|
||||
|
||||
if (!binding || parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyStyle = size === 'md'
|
||||
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
|
||||
: baseKeyStyle;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||
fontWeight: muted ? 500 : 600,
|
||||
}}
|
||||
>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={`${part}-${index}`}>
|
||||
<kbd style={keyStyle}>{part}</kbd>
|
||||
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeyDisplay;
|
||||
@@ -0,0 +1,21 @@
|
||||
.workbench-scrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '@app/types/workbench';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/layout/Workbench.css';
|
||||
|
||||
import TopControls from '@app/components/shared/TopControls';
|
||||
import FileEditor from '@app/components/fileEditor/FileEditor';
|
||||
import PageEditor from '@app/components/pageEditor/PageEditor';
|
||||
import PageEditorControls from '@app/components/pageEditor/PageEditorControls';
|
||||
import Viewer from '@app/components/viewer/Viewer';
|
||||
import LandingPage from '@app/components/shared/LandingPage';
|
||||
import Footer from '@app/components/shared/Footer';
|
||||
import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = selectors.getFiles();
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
sidebarsVisible,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible,
|
||||
customWorkbenchViews,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
|
||||
// Get navigation state - this is the source of truth
|
||||
const { selectedTool: selectedToolId } = useNavigationState();
|
||||
|
||||
// Get tool registry from context (instead of direct hook call)
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
// Get active file index from ViewerContext
|
||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
const previousMode = sessionStorage.getItem('previousMode');
|
||||
if (previousMode === 'split') {
|
||||
// Use context's handleToolSelect which coordinates tool selection and view changes
|
||||
handleToolSelect('split');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else if (previousMode === 'compress') {
|
||||
handleToolSelect('compress');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else if (previousMode === 'convert') {
|
||||
handleToolSelect('convert');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else {
|
||||
setCurrentView('fileEditor');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (activeFiles.length === 0) {
|
||||
return (
|
||||
<LandingPage
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentView) {
|
||||
case "fileEditor":
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
{...(!selectedToolId && {
|
||||
onOpenPageEditor: () => {
|
||||
setCurrentView("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
addFiles(filesToMerge);
|
||||
setCurrentView("viewer");
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
case "viewer":
|
||||
return (
|
||||
<Viewer
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
case "pageEditor":
|
||||
return (
|
||||
<>
|
||||
<PageEditor
|
||||
onFunctionsReady={setPageEditorFunctions}
|
||||
/>
|
||||
{pageEditorFunctions && (
|
||||
<PageEditorControls
|
||||
onClosePdf={pageEditorFunctions.closePdf}
|
||||
onUndo={pageEditorFunctions.handleUndo}
|
||||
onRedo={pageEditorFunctions.handleRedo}
|
||||
canUndo={pageEditorFunctions.canUndo}
|
||||
canRedo={pageEditorFunctions.canRedo}
|
||||
onRotate={pageEditorFunctions.handleRotate}
|
||||
onDelete={pageEditorFunctions.handleDelete}
|
||||
onSplit={pageEditorFunctions.handleSplit}
|
||||
onSplitAll={pageEditorFunctions.handleSplitAll}
|
||||
onPageBreak={pageEditorFunctions.handlePageBreak}
|
||||
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
|
||||
onExportAll={pageEditorFunctions.onExportAll}
|
||||
exportLoading={pageEditorFunctions.exportLoading}
|
||||
selectionMode={pageEditorFunctions.selectionMode}
|
||||
selectedPageIds={pageEditorFunctions.selectedPageIds}
|
||||
displayDocument={pageEditorFunctions.displayDocument}
|
||||
splitPositions={pageEditorFunctions.splitPositions}
|
||||
totalPages={pageEditorFunctions.totalPages}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
}
|
||||
}
|
||||
return <LandingPage />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
? {} // No background color in rainbow mode
|
||||
: { backgroundColor: 'var(--bg-background)' }
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map(f => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={setActiveFileIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
<Footer analyticsEnabled={config?.enableAnalytics === true} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Glow effect for tour highlighted area */
|
||||
.tour-highlight-glow {
|
||||
stroke: var(--mantine-primary-color-filled);
|
||||
stroke-width: 3px;
|
||||
rx: 8px;
|
||||
ry: 8px;
|
||||
filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled));
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import React from "react";
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
|
||||
// Enum case order defines order steps will appear
|
||||
enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
// Sync tour open state with context and reset to step 0 when reopening
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
// Tour is being opened (Help button pressed), reset to first step
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
} = useTourOrchestration();
|
||||
|
||||
// Define steps as object keyed by enum - TypeScript ensures all keys are present
|
||||
const stepsConfig: Record<TourStep, StepType> = {
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[TourStep.SELECT_CROP_TOOL]: {
|
||||
selector: '[data-tour="tool-button-crop"]',
|
||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => selectCropTool(),
|
||||
},
|
||||
[TourStep.TOOL_INTERFACE]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILES_BUTTON]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
}
|
||||
},
|
||||
[TourStep.WORKBENCH]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToActiveFiles(),
|
||||
},
|
||||
[TourStep.FILE_CHECKBOX]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => modifyCropSettings(),
|
||||
},
|
||||
[TourStep.RUN_BUTTON]: {
|
||||
selector: '[data-tour="run-button"]',
|
||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
actionAfter: () => executeTool(),
|
||||
},
|
||||
[TourStep.RESULTS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILE_REPLACEMENT]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.PIN_BUTTON]: {
|
||||
selector: '[data-tour="file-card-pin"]',
|
||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
|
||||
// Convert to array using enum's numeric ordering
|
||||
const steps = Object.values(stepsConfig);
|
||||
|
||||
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}) => {
|
||||
if (steps && currentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
restoreWorkbenchState();
|
||||
completeTour();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
||||
setIsOpen(false);
|
||||
restoreWorkbenchState();
|
||||
completeTour();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TourWelcomeModal
|
||||
opened={showWelcomeModal}
|
||||
onStartTour={() => {
|
||||
setShowWelcomeModal(false);
|
||||
startTour();
|
||||
}}
|
||||
onMaybeLater={() => {
|
||||
setShowWelcomeModal(false);
|
||||
}}
|
||||
onDontShowAgain={() => {
|
||||
setShowWelcomeModal(false);
|
||||
completeTour();
|
||||
}}
|
||||
/>
|
||||
<TourProvider
|
||||
steps={steps}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
e.stopPropagation();
|
||||
advanceTour(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
// Handle right arrow key to advance tour
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
advanceTour(clickProps);
|
||||
}
|
||||
// Handle escape key to close tour
|
||||
else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
handleCloseTour(clickProps);
|
||||
}
|
||||
}}
|
||||
styles={{
|
||||
popover: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '400px',
|
||||
}),
|
||||
maskArea: (base) => ({
|
||||
...base,
|
||||
rx: 8,
|
||||
}),
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
}),
|
||||
controls: (base) => ({
|
||||
...base,
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
}}
|
||||
highlightedMaskClassName="tour-highlight-glow"
|
||||
showNavigation={true}
|
||||
showBadge={false}
|
||||
showCloseButton={true}
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={true}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
const isLast = currentStep === stepsLength - 1;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
advanceTour({ setCurrentStep, currentStep, steps, setIsOpen });
|
||||
}}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowForwardIcon />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
style={{ position: 'absolute', top: '8px', right: '8px' }}
|
||||
/>
|
||||
),
|
||||
Content: ({ content } : {content: string}) => (
|
||||
<div
|
||||
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent />
|
||||
</TourProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
interface TourWelcomeModalProps {
|
||||
opened: boolean;
|
||||
onStartTour: () => void;
|
||||
onMaybeLater: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}
|
||||
|
||||
export default function TourWelcomeModal({
|
||||
opened,
|
||||
onStartTour,
|
||||
onMaybeLater,
|
||||
onDontShowAgain,
|
||||
}: TourWelcomeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onMaybeLater}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Title order={2}>
|
||||
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
|
||||
</Title>
|
||||
<Text size="md" c="dimmed">
|
||||
{t('onboarding.welcomeModal.description',
|
||||
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
size="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('onboarding.welcomeModal.helpHint',
|
||||
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={onStartTour}
|
||||
size="md"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
>
|
||||
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
|
||||
</Button>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
onClick={onMaybeLater}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onDontShowAgain}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
|
||||
import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput';
|
||||
import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay';
|
||||
import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
onUpdatePagesFromCSV: (override?: string) => void;
|
||||
}
|
||||
|
||||
const BulkSelectionPanel = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
onUpdatePagesFromCSV,
|
||||
}: BulkSelectionPanelProps) => {
|
||||
const [syntaxError, setSyntaxError] = useState<string | null>(null);
|
||||
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
|
||||
const maxPages = displayDocument?.pages?.length ?? 0;
|
||||
|
||||
|
||||
// Validate input syntax and show lightweight feedback
|
||||
useEffect(() => {
|
||||
const text = (csvInput || '').trim();
|
||||
if (!text) {
|
||||
setSyntaxError(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { warning } = parseSelectionWithDiagnostics(text, maxPages);
|
||||
setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null);
|
||||
} catch {
|
||||
setSyntaxError('There is a syntax issue. See Page Selection tips for help.');
|
||||
}
|
||||
}, [csvInput, maxPages]);
|
||||
|
||||
const handleClear = () => {
|
||||
setCsvInput('');
|
||||
onUpdatePagesFromCSV('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.panelContainer}>
|
||||
<PageSelectionInput
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
|
||||
onClear={handleClear}
|
||||
advancedOpened={advancedOpened}
|
||||
onToggleAdvanced={setAdvancedOpened}
|
||||
/>
|
||||
|
||||
<SelectedPagesDisplay
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
syntaxError={syntaxError}
|
||||
/>
|
||||
|
||||
<AdvancedSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
|
||||
maxPages={maxPages}
|
||||
advancedOpened={advancedOpened}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkSelectionPanel;
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
splitAfter?: boolean;
|
||||
}
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: string[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DragDropGrid = <T extends DragDropItem>({
|
||||
items,
|
||||
renderItem,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Responsive grid configuration
|
||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
|
||||
// Calculate items per row based on container width
|
||||
const calculateItemsPerRow = useCallback(() => {
|
||||
if (!containerRef.current) return 4; // Default fallback
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
if (containerWidth === 0) return 4; // Container not measured yet
|
||||
|
||||
// Convert rem to pixels for calculation
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
|
||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
||||
const calculated = Math.floor(availableWidth / itemWithGap);
|
||||
|
||||
return Math.max(1, calculated); // At least 1 item per row
|
||||
}, []);
|
||||
|
||||
// Update items per row when container resizes
|
||||
useEffect(() => {
|
||||
const updateLayout = () => {
|
||||
const newItemsPerRow = calculateItemsPerRow();
|
||||
setItemsPerRow(newItemsPerRow);
|
||||
};
|
||||
|
||||
// Initial calculation
|
||||
updateLayout();
|
||||
|
||||
// Listen for window resize
|
||||
window.addEventListener('resize', updateLayout);
|
||||
|
||||
// Use ResizeObserver for container size changes
|
||||
const resizeObserver = new ResizeObserver(updateLayout);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateLayout);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateItemsPerRow]);
|
||||
|
||||
// Virtualization with react-virtual library
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: Math.ceil(items.length / itemsPerRow),
|
||||
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
||||
estimateSize: () => {
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
||||
},
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
// Calculate optimal width for centering
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
margin: '0 auto',
|
||||
maxWidth: `${gridWidth}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||
const rowItems = items.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: GRID_CONSTANTS.ITEM_GAP,
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Item */}
|
||||
{renderItem(item, actualIndex, itemRefs)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragDropGrid;
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
|
||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
||||
import { useFileContext } from '@app/contexts/FileContext';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string | null;
|
||||
size: number;
|
||||
modifiedAt?: number | string | Date;
|
||||
}
|
||||
|
||||
interface FileThumbnailProps {
|
||||
file: FileItem;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: string[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
|
||||
const FileThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
isSupported = true,
|
||||
}: FileThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
// Prefer parent-provided handler if available
|
||||
if (typeof onDownloadFile === 'function') {
|
||||
onDownloadFile(file.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: attempt to download using the File object if provided
|
||||
const maybeFile = (file as unknown as { file?: File }).file;
|
||||
if (maybeFile instanceof File) {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(maybeFile);
|
||||
link.download = maybeFile.name || file.name || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can't find a way to download, surface a status message
|
||||
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
|
||||
}, [file, onDownloadFile, onSetStatus, t]);
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
}
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id
|
||||
}),
|
||||
canDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||
|
||||
// Update dropdown width on resize
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
||||
useEffect(() => {
|
||||
if (!showActions) return;
|
||||
|
||||
const isInsideCard = (target: EventTarget | null) => {
|
||||
const container = dragElementRef.current;
|
||||
if (!container) return false;
|
||||
return target instanceof Node && container.contains(target);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// On touch devices, close if the touch target is outside the card
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
}, [showActions]);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileElementRef}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{
|
||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${
|
||||
isSelected ? styles.headerSelected : styles.headerResting
|
||||
}`}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
color="var(--checkbox-checked-bg)"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>
|
||||
{t('unsupported', 'Unsupported')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.kebab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{/* Actions overlay */}
|
||||
{showActions && (
|
||||
<div
|
||||
className={styles.actionsOverlay}
|
||||
style={{ width: actionsWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => {
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus?.(`Unpinned ${file.name}`);
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus?.(`Pinned ${file.name}`);
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t('delete', 'Delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content area */}
|
||||
<div className="file-container w-[90%] h-[80%] relative">
|
||||
{/* Stacked file effect - multiple shadows to simulate pages */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
||||
}}
|
||||
>
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
onError={(e) => {
|
||||
// Hide broken image if blob URL was revoked
|
||||
const img = e.target as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
maxHeight: '80%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 0,
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--border-default)',
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
alignSelf: 'start'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pin indicator (bottom-left) */}
|
||||
{isPinned && (
|
||||
<span className={styles.pinIndicator} aria-hidden>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FileThumbnail);
|
||||
@@ -0,0 +1,101 @@
|
||||
/* Page container hover effects - optimized for smooth scrolling */
|
||||
.pageContainer {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
/* Enable hardware acceleration for smoother scrolling */
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.pageContainer:hover {
|
||||
transform: scale(1.02) translateZ(0);
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageNumber {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageHoverControls {
|
||||
opacity: 0.95 !important;
|
||||
}
|
||||
|
||||
/* Checkbox container - prevent transform inheritance */
|
||||
.checkboxContainer {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Page movement animations */
|
||||
.pageMoveAnimation {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.pageMoving {
|
||||
z-index: 10;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Multi-page drag indicator */
|
||||
.multiDragIndicator {
|
||||
position: fixed;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Action styles */
|
||||
.actionRow:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.actionDanger {
|
||||
color: var(--text-brand-accent);
|
||||
}
|
||||
|
||||
.actionsDivider {
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.pinIndicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.35); /* match drag handle color */
|
||||
}
|
||||
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
background: #6B7280;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 20px;
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useNavigationGuard } from "@app/contexts/NavigationContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "@app/types/pageEditor";
|
||||
import { pdfExportService } from "@app/services/pdfExportService";
|
||||
import { documentManipulationService } from "@app/services/documentManipulationService";
|
||||
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
|
||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
import '@app/components/pageEditor/PageEditor.module.css';
|
||||
import PageThumbnail from '@app/components/pageEditor/PageThumbnail';
|
||||
import DragDropGrid from '@app/components/pageEditor/DragDropGrid';
|
||||
import SkeletonLoader from '@app/components/shared/SkeletonLoader';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { FileId } from "@app/types/file";
|
||||
|
||||
import {
|
||||
DeletePagesCommand,
|
||||
ReorderPagesCommand,
|
||||
SplitCommand,
|
||||
BulkRotateCommand,
|
||||
PageBreakCommand,
|
||||
UndoManager
|
||||
} from '@app/components/pageEditor/commands/pageCommands';
|
||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
||||
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
|
||||
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
|
||||
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
|
||||
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
|
||||
|
||||
export interface PageEditorProps {
|
||||
onFunctionsReady?: (functions: PageEditorFunctions) => void;
|
||||
}
|
||||
|
||||
const PageEditor = ({
|
||||
onFunctionsReady,
|
||||
}: PageEditorProps) => {
|
||||
|
||||
// Use split contexts to prevent re-renders
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
// Edit state management
|
||||
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
||||
|
||||
// DOM-first undo manager (replaces the old React state undo system)
|
||||
const undoManagerRef = useRef(new UndoManager());
|
||||
|
||||
// Document state management
|
||||
const { document: mergedPdfDocument } = usePageDocument();
|
||||
|
||||
|
||||
// UI state management
|
||||
const {
|
||||
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
|
||||
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
|
||||
togglePage, toggleSelectAll, animateReorder
|
||||
} = usePageEditorState();
|
||||
|
||||
const [csvInput, setCsvInput] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setCsvInput('');
|
||||
}, [activeFilesSignature]);
|
||||
|
||||
// Grid container ref for positioning split indicators
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State to trigger re-renders when container size changes
|
||||
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
// Undo/Redo state
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
// Update undo/redo state
|
||||
const updateUndoRedoState = useCallback(() => {
|
||||
setCanUndo(undoManagerRef.current.canUndo());
|
||||
setCanRedo(undoManagerRef.current.canRedo());
|
||||
}, []);
|
||||
|
||||
// Set up undo manager callback
|
||||
useEffect(() => {
|
||||
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
|
||||
// Initialize state
|
||||
updateUndoRedoState();
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
// Wrapper for executeCommand to track unsaved changes
|
||||
const executeCommandWithTracking = useCallback((command: any) => {
|
||||
undoManagerRef.current.executeCommand(command);
|
||||
setHasUnsavedChanges(true);
|
||||
}, [setHasUnsavedChanges]);
|
||||
|
||||
// Watch for container size changes to update split line positions
|
||||
useEffect(() => {
|
||||
const container = gridContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerDimensions({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Interface functions for parent component
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
const totalPages = displayDocument?.pages.length ?? 0;
|
||||
const selectedPageCount = selectedPageIds.length;
|
||||
|
||||
// Utility functions to convert between page IDs and page numbers
|
||||
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
|
||||
if (!displayDocument) return [];
|
||||
return pageIds.map(id => {
|
||||
const page = displayDocument.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0);
|
||||
}, [displayDocument]);
|
||||
|
||||
const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => {
|
||||
if (!displayDocument) return [];
|
||||
return pageNumbers.map(num => {
|
||||
const page = displayDocument.pages.find(p => p.pageNumber === num);
|
||||
return page?.id || '';
|
||||
}).filter(id => id !== '');
|
||||
}, [displayDocument]);
|
||||
|
||||
// Select all pages by default when document initially loads
|
||||
const hasInitializedSelection = useRef(false);
|
||||
useEffect(() => {
|
||||
if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) {
|
||||
const allPageIds = displayDocument.pages.map(p => p.id);
|
||||
setSelectedPageIds(allPageIds);
|
||||
setSelectionMode(true);
|
||||
hasInitializedSelection.current = true;
|
||||
}
|
||||
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
|
||||
|
||||
// DOM-first command handlers
|
||||
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}, [executeCommandWithTracking]);
|
||||
|
||||
// Command factory functions for PageThumbnail
|
||||
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}
|
||||
}), [executeCommandWithTracking]);
|
||||
|
||||
const createDeleteCommand = useCallback((pageIds: string[]) => ({
|
||||
execute: () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
const pagesToDelete = pageIds.map(pageId => {
|
||||
|
||||
const page = displayDocument.pages.find(p => p.id === pageId);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0);
|
||||
|
||||
if (pagesToDelete.length > 0) {
|
||||
const deleteCommand = new DeletePagesCommand(
|
||||
pagesToDelete,
|
||||
() => displayDocument,
|
||||
setEditedDocument,
|
||||
(pageNumbers: number[]) => {
|
||||
const pageIds = getPageIdsFromNumbers(pageNumbers);
|
||||
setSelectedPageIds(pageIds);
|
||||
},
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}
|
||||
}
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const createSplitCommand = useCallback((position: number) => ({
|
||||
execute: () => {
|
||||
const splitCommand = new SplitCommand(
|
||||
position,
|
||||
() => splitPositions,
|
||||
setSplitPositions
|
||||
);
|
||||
executeCommandWithTracking(splitCommand);
|
||||
}
|
||||
}), [splitPositions, executeCommandWithTracking]);
|
||||
|
||||
// Command executor for PageThumbnail
|
||||
const executeCommand = useCallback((command: any) => {
|
||||
if (command && typeof command.execute === 'function') {
|
||||
command.execute();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
undoManagerRef.current.undo();
|
||||
}, []);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
undoManagerRef.current.redo();
|
||||
}, []);
|
||||
|
||||
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
const rotation = direction === 'left' ? -90 : 90;
|
||||
|
||||
handleRotatePages(selectedPageIds, rotation);
|
||||
}, [displayDocument, selectedPageIds, handleRotatePages]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
// Convert selected page IDs to page numbers for the command
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
|
||||
const deleteCommand = new DeletePagesCommand(
|
||||
selectedPageNumbers,
|
||||
() => displayDocument,
|
||||
setEditedDocument,
|
||||
(pageNumbers: number[]) => {
|
||||
const pageIds = getPageIdsFromNumbers(pageNumbers);
|
||||
setSelectedPageIds(pageIds);
|
||||
},
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => selectedPageNumbers,
|
||||
closePdf
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
|
||||
|
||||
const handleDeletePage = useCallback((pageNumber: number) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
const deleteCommand = new DeletePagesCommand(
|
||||
[pageNumber],
|
||||
() => displayDocument,
|
||||
setEditedDocument,
|
||||
(pageNumbers: number[]) => {
|
||||
const pageIds = getPageIdsFromNumbers(pageNumbers);
|
||||
setSelectedPageIds(pageIds);
|
||||
},
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
// Convert selected page IDs to page numbers, then to split positions (0-based indices)
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
const selectedPositions: number[] = [];
|
||||
selectedPageNumbers.forEach(pageNum => {
|
||||
const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum);
|
||||
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
|
||||
// Only allow splits before the last page
|
||||
selectedPositions.push(pageIndex);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedPositions.length === 0) return;
|
||||
|
||||
// Smart toggle logic: follow the majority, default to adding splits if equal
|
||||
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
|
||||
const noSplitsCount = selectedPositions.length - existingSplitsCount;
|
||||
|
||||
// Remove splits only if majority already have splits
|
||||
// If equal (50/50), default to adding splits
|
||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
|
||||
const newSplitPositions = new Set(splitPositions);
|
||||
|
||||
if (shouldRemoveSplits) {
|
||||
// Remove splits from all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
|
||||
} else {
|
||||
// Add splits to all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.add(pos));
|
||||
}
|
||||
|
||||
// Create a custom command that sets the final state directly
|
||||
const smartSplitCommand = {
|
||||
execute: () => setSplitPositions(newSplitPositions),
|
||||
undo: () => setSplitPositions(splitPositions),
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplitAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
// Convert selected page IDs to page numbers, then to split positions (0-based indices)
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
const selectedPositions: number[] = [];
|
||||
selectedPageNumbers.forEach(pageNum => {
|
||||
const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum);
|
||||
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
|
||||
// Only allow splits before the last page
|
||||
selectedPositions.push(pageIndex);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedPositions.length === 0) return;
|
||||
|
||||
// Smart toggle logic: follow the majority, default to adding splits if equal
|
||||
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
|
||||
const noSplitsCount = selectedPositions.length - existingSplitsCount;
|
||||
|
||||
// Remove splits only if majority already have splits
|
||||
// If equal (50/50), default to adding splits
|
||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
const newSplitPositions = new Set(splitPositions);
|
||||
|
||||
if (shouldRemoveSplits) {
|
||||
// Remove splits from all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
|
||||
} else {
|
||||
// Add splits to all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.add(pos));
|
||||
}
|
||||
|
||||
// Create a custom command that sets the final state directly
|
||||
const smartSplitCommand = {
|
||||
execute: () => setSplitPositions(newSplitPositions),
|
||||
undo: () => setSplitPositions(splitPositions),
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreak = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
// Convert selected page IDs to page numbers for the command
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
|
||||
const pageBreakCommand = new PageBreakCommand(
|
||||
selectedPageNumbers,
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreakAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
// Convert selected page IDs to page numbers for the command
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
|
||||
const pageBreakCommand = new PageBreakCommand(
|
||||
selectedPageNumbers,
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
|
||||
if (!displayDocument || files.length === 0) return;
|
||||
|
||||
try {
|
||||
const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage);
|
||||
if (!targetPage) return;
|
||||
|
||||
await actions.addFiles(files, { insertAfterPageId: targetPage.id });
|
||||
} catch (error) {
|
||||
console.error('Failed to insert files:', error);
|
||||
}
|
||||
}, [displayDocument, actions]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (!displayDocument) return;
|
||||
const allPageIds = displayDocument.pages.map(p => p.id);
|
||||
toggleSelectAll(allPageIds);
|
||||
}, [displayDocument, toggleSelectAll]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedPageIds([]);
|
||||
}, [setSelectedPageIds]);
|
||||
|
||||
const handleSetSelectedPages = useCallback((pageNumbers: number[]) => {
|
||||
const pageIds = getPageIdsFromNumbers(pageNumbers);
|
||||
setSelectedPageIds(pageIds);
|
||||
}, [getPageIdsFromNumbers, setSelectedPageIds]);
|
||||
|
||||
const updatePagesFromCSV = useCallback((override?: string) => {
|
||||
if (totalPages === 0) return;
|
||||
const normalized = parseSelection(override ?? csvInput, totalPages);
|
||||
handleSetSelectedPages(normalized);
|
||||
}, [csvInput, totalPages, handleSetSelectedPages]);
|
||||
|
||||
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
// Convert selectedPageIds to page numbers for the reorder command
|
||||
const selectedPages = selectedPageIds ? getPageNumbersFromIds(selectedPageIds) : undefined;
|
||||
|
||||
const reorderCommand = new ReorderPagesCommand(
|
||||
sourcePageNumber,
|
||||
targetIndex,
|
||||
selectedPages,
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
// Helper function to collect source files for multi-file export
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
const sourceFiles = new Map<FileId, File>();
|
||||
|
||||
// Always include original files
|
||||
activeFileIds.forEach(fileId => {
|
||||
const file = selectors.getFile(fileId);
|
||||
if (file) {
|
||||
sourceFiles.set(fileId, file);
|
||||
}
|
||||
});
|
||||
|
||||
// Use multi-file export if we have multiple original files
|
||||
const hasInsertedFiles = false;
|
||||
const hasMultipleOriginalFiles = activeFileIds.length > 1;
|
||||
|
||||
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
|
||||
return null; // Use single-file export method
|
||||
}
|
||||
|
||||
return sourceFiles.size > 0 ? sourceFiles : null;
|
||||
}, [activeFileIds, selectors]);
|
||||
|
||||
// Helper function to generate proper filename for exports
|
||||
const getExportFilename = useCallback((): string => {
|
||||
if (activeFileIds.length <= 1) {
|
||||
// Single file - use original name
|
||||
return displayDocument?.name || 'document.pdf';
|
||||
}
|
||||
|
||||
// Multiple files - use first file name with " (merged)" suffix
|
||||
const firstFile = selectors.getFile(activeFileIds[0]);
|
||||
if (firstFile) {
|
||||
const baseName = firstFile.name.replace(/\.pdf$/i, '');
|
||||
return `${baseName} (merged).pdf`;
|
||||
}
|
||||
|
||||
return 'merged-document.pdf';
|
||||
}, [activeFileIds, selectors, displayDocument]);
|
||||
|
||||
const onExportSelected = useCallback(async () => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
);
|
||||
|
||||
// For selected pages export, we work with the first document (or single document)
|
||||
const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
|
||||
|
||||
// Step 2: Use the already selected page IDs
|
||||
// Filter to only include IDs that exist in the document with DOM state
|
||||
const validSelectedPageIds = selectedPageIds.filter(pageId =>
|
||||
documentWithDOMState.pages.some(p => p.id === pageId)
|
||||
);
|
||||
|
||||
// Step 3: Export with pdfExportService
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(
|
||||
documentWithDOMState,
|
||||
sourceFiles,
|
||||
validSelectedPageIds,
|
||||
{ selectedOnly: true, filename: exportFilename }
|
||||
)
|
||||
: await pdfExportService.exportPDF(
|
||||
documentWithDOMState,
|
||||
validSelectedPageIds,
|
||||
{ selectedOnly: true, filename: exportFilename }
|
||||
);
|
||||
|
||||
// Step 4: Download the result
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
const onExportAll = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
// Step 3: Download
|
||||
if (files.length > 1) {
|
||||
// Multiple files - create ZIP
|
||||
const JSZip = await import('jszip');
|
||||
const zip = new JSZip.default();
|
||||
|
||||
files.forEach((file) => {
|
||||
zip.file(file.name, file);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const exportFilename = getExportFilename();
|
||||
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
} else {
|
||||
// Single file - download directly
|
||||
const file = files[0];
|
||||
pdfExportService.downloadFile(file, file.name);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
actions.clearAllFiles();
|
||||
|
||||
undoManagerRef.current.clear();
|
||||
setSelectedPageIds([]);
|
||||
setSelectionMode(false);
|
||||
}, [actions]);
|
||||
|
||||
usePageEditorRightRailButtons({
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument: displayDocument || undefined,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount: activeFileIds.length,
|
||||
closePdf,
|
||||
});
|
||||
|
||||
// Export preview function - defined after export functions to avoid circular dependency
|
||||
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
// For now, trigger the actual export directly
|
||||
// In the original, this would show a preview modal first
|
||||
if (selectedOnly) {
|
||||
onExportSelected();
|
||||
} else {
|
||||
onExportAll();
|
||||
}
|
||||
}, [displayDocument, onExportSelected, onExportAll]);
|
||||
|
||||
// Expose functions to parent component
|
||||
useEffect(() => {
|
||||
if (onFunctionsReady) {
|
||||
onFunctionsReady({
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
handleSplitAll,
|
||||
handlePageBreak,
|
||||
handlePageBreakAll,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleSetSelectedPages,
|
||||
showExportPreview: handleExportPreview,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
applyChanges,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPageIds,
|
||||
displayDocument: displayDocument || undefined,
|
||||
splitPositions,
|
||||
totalPages: displayDocument?.pages.length || 0,
|
||||
closePdf,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll,
|
||||
handlePageBreak, handlePageBreakAll, handleSelectAll, handleDeselectAll, handleSetSelectedPages, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading,
|
||||
selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf
|
||||
]);
|
||||
|
||||
// Display all pages - use edited or original document
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
<Center h='100%'>
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📄</Text>
|
||||
<Text c="dimmed">No PDF files loaded</Text>
|
||||
<Text size="sm" c="dimmed">Add files to start editing pages</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{!mergedPdfDocument && globalProcessing && (
|
||||
<Box p={0}>
|
||||
<SkeletonLoader type="controls" />
|
||||
<SkeletonLoader type="pageGrid" count={8} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{displayDocument && (
|
||||
<Box ref={gridContainerRef} p={0} pb="15rem" style={{ position: 'relative' }}>
|
||||
|
||||
|
||||
{/* Split Lines Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Calculate remToPx once outside the map to avoid layout thrashing
|
||||
const containerWidth = containerDimensions.width;
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
|
||||
return Array.from(splitPositions).map((position) => {
|
||||
|
||||
// Calculate items per row using DragDropGrid's logic
|
||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
||||
const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap));
|
||||
|
||||
// Calculate position within the grid (same as DragDropGrid)
|
||||
const row = Math.floor(position / itemsPerRow);
|
||||
const col = position % itemsPerRow;
|
||||
|
||||
// Position split line between pages (after the current page)
|
||||
// Calculate grid centering offset (same as DragDropGrid)
|
||||
const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP;
|
||||
const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2);
|
||||
|
||||
const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2);
|
||||
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`split-${position}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: leftPosition,
|
||||
top: topPosition,
|
||||
width: '1px',
|
||||
height: `calc(${GRID_CONSTANTS.ITEM_HEIGHT} * 0.9)`, // Match page container height (90%)
|
||||
borderLeft: '1px dashed #3b82f6'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Pages Grid */}
|
||||
<DragDropGrid
|
||||
items={displayedPages}
|
||||
selectedItems={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onReorderPages={handleReorderPages}
|
||||
renderItem={(page, index, refs) => (
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={displayDocument.pages.length}
|
||||
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
|
||||
selectedPageIds={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
pageRefs={refs}
|
||||
onReorderPages={handleReorderPages}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
onExecuteCommand={executeCommand}
|
||||
onSetStatus={() => {}}
|
||||
onSetMovingPage={setMovingPage}
|
||||
onDeletePage={handleDeletePage}
|
||||
createRotateCommand={createRotateCommand}
|
||||
createDeleteCommand={createDeleteCommand}
|
||||
createSplitCommand={createSplitCommand}
|
||||
pdfDocument={displayDocument}
|
||||
setPdfDocument={setEditedDocument}
|
||||
splitPositions={splitPositions}
|
||||
onInsertFiles={handleInsertFiles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
onExportAndContinue={async () => {
|
||||
await onExportAll();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditor;
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
|
||||
|
||||
interface PageEditorControlsProps {
|
||||
// Close/Reset functions
|
||||
onClosePdf: () => void;
|
||||
|
||||
// Undo/Redo
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
|
||||
// Page operations
|
||||
onRotate: (direction: 'left' | 'right') => void;
|
||||
onDelete: () => void;
|
||||
onSplit: () => void;
|
||||
onSplitAll: () => void;
|
||||
onPageBreak: () => void;
|
||||
onPageBreakAll: () => void;
|
||||
|
||||
// Export functions (moved to right rail)
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
|
||||
// Split state (for tooltip logic)
|
||||
splitPositions?: Set<number>;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
const PageEditorControls = ({
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onRotate,
|
||||
onDelete,
|
||||
onSplit,
|
||||
onPageBreak,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
totalPages
|
||||
}: PageEditorControlsProps) => {
|
||||
// Calculate split tooltip text using smart toggle logic
|
||||
const getSplitTooltip = () => {
|
||||
if (!splitPositions || !totalPages || selectedPageIds.length === 0) {
|
||||
return "Split Selected";
|
||||
}
|
||||
|
||||
// Convert selected pages to split positions (same logic as handleSplit)
|
||||
const selectedPageNumbers = displayDocument ? selectedPageIds.map(id => {
|
||||
const page = displayDocument.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0) : [];
|
||||
const selectedSplitPositions = selectedPageNumbers.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1);
|
||||
|
||||
if (selectedSplitPositions.length === 0) {
|
||||
return "Split Selected";
|
||||
}
|
||||
|
||||
// Smart toggle logic: follow the majority, default to adding splits if equal
|
||||
const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length;
|
||||
const noSplitsCount = selectedSplitPositions.length - existingSplitsCount;
|
||||
|
||||
// Remove splits only if majority already have splits
|
||||
// If equal (50/50), default to adding splits
|
||||
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
if (willRemoveSplits) {
|
||||
return existingSplitsCount === selectedSplitPositions.length
|
||||
? "Remove All Selected Splits"
|
||||
: "Remove Selected Splits";
|
||||
} else {
|
||||
return existingSplitsCount === 0
|
||||
? "Split Selected"
|
||||
: "Complete Selected Splits";
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate page break tooltip text
|
||||
const getPageBreakTooltip = () => {
|
||||
return selectedPageIds.length > 0
|
||||
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? 's' : ''}`
|
||||
: "Insert Page Breaks";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 360,
|
||||
maxWidth: 700,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
padding: "1rem",
|
||||
paddingBottom: "1rem"
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Page Operations */}
|
||||
<Tooltip label="Rotate Selected Left">
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Rotate Selected Right">
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete Selected">
|
||||
<ActionIcon
|
||||
onClick={onDelete}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={getSplitTooltip()}>
|
||||
<ActionIcon
|
||||
onClick={onSplit}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<ContentCutIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={getPageBreakTooltip()}>
|
||||
<ActionIcon
|
||||
onClick={onPageBreak}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
<InsertPageBreakIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditorControls;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import BulkSelectionPanel from '@app/components/pageEditor/BulkSelectionPanel';
|
||||
|
||||
interface PageSelectByNumberButtonProps {
|
||||
disabled: boolean;
|
||||
totalPages: number;
|
||||
label: string;
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
updatePagesFromCSV: (override?: string) => void;
|
||||
}
|
||||
|
||||
export default function PageSelectByNumberButton({
|
||||
disabled,
|
||||
totalPages,
|
||||
label,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
}: PageSelectByNumberButtonProps) {
|
||||
return (
|
||||
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Text, Checkbox } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
|
||||
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
|
||||
|
||||
interface PageThumbnailProps {
|
||||
page: PDFPage;
|
||||
index: number;
|
||||
totalPages: number;
|
||||
originalFile?: File;
|
||||
selectedPageIds: string[];
|
||||
selectionMode: boolean;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: () => void;
|
||||
onExecuteCommand: (command: { execute: () => void }) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onSetMovingPage: (page: number | null) => void;
|
||||
onDeletePage: (pageNumber: number) => void;
|
||||
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
|
||||
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
|
||||
createSplitCommand: (position: number) => { execute: () => void };
|
||||
pdfDocument: PDFDocument;
|
||||
setPdfDocument: (doc: PDFDocument) => void;
|
||||
splitPositions: Set<number>;
|
||||
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
|
||||
}
|
||||
|
||||
const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
page,
|
||||
index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
selectedPageIds,
|
||||
selectionMode,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
pageRefs,
|
||||
onReorderPages,
|
||||
onTogglePage,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
onSetMovingPage,
|
||||
onDeletePage,
|
||||
createRotateCommand,
|
||||
createSplitCommand,
|
||||
pdfDocument,
|
||||
splitPositions,
|
||||
onInsertFiles,
|
||||
}: PageThumbnailProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
// Calculate document aspect ratio from first non-blank page
|
||||
const getDocumentAspectRatio = useCallback(() => {
|
||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||
const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail);
|
||||
if (firstRealPage?.thumbnail) {
|
||||
// Try to get aspect ratio from an actual thumbnail image
|
||||
// For now, default to A4 but could be enhanced to measure image dimensions
|
||||
return '1 / 1.414'; // A4 ratio as fallback
|
||||
}
|
||||
return '1 / 1.414'; // Default A4 ratio
|
||||
}, [pdfDocument.pages]);
|
||||
|
||||
// Update thumbnail URL when page prop changes
|
||||
useEffect(() => {
|
||||
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
||||
setThumbnailUrl(page.thumbnail);
|
||||
}
|
||||
}, [page.thumbnail, thumbnailUrl]);
|
||||
|
||||
// Request thumbnail if missing (on-demand, virtualized approach)
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
// If we already have a thumbnail, use it
|
||||
if (page.thumbnail) {
|
||||
setThumbnailUrl(page.thumbnail);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cachedThumbnail = getThumbnailFromCache(page.id);
|
||||
if (cachedThumbnail) {
|
||||
setThumbnailUrl(cachedThumbnail);
|
||||
return;
|
||||
}
|
||||
|
||||
// Request thumbnail generation if we have the original file
|
||||
if (originalFile) {
|
||||
const pageNumber = page.originalPageNumber;
|
||||
|
||||
requestThumbnail(page.id, originalFile, pageNumber)
|
||||
.then(thumbnail => {
|
||||
if (!isCancelled && thumbnail) {
|
||||
setThumbnailUrl(thumbnail);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(`Failed to generate thumbnail for ${page.id}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
|
||||
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
pageNumber: page.pageNumber,
|
||||
pageId: page.id,
|
||||
selectedPageIds: [page.id]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (location.current.dropTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = location.current.dropTargets[0];
|
||||
const targetData = dropTarget.data;
|
||||
|
||||
if (targetData.type === 'page') {
|
||||
const targetPageNumber = targetData.pageNumber as number;
|
||||
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||
if (targetIndex !== -1) {
|
||||
onReorderPages(page.pageNumber, targetIndex, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
element.style.cursor = 'grab';
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: (_) => {}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
} else {
|
||||
pageRefs.current.delete(page.id);
|
||||
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||
(dragElementRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
|
||||
|
||||
// DOM command handlers
|
||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Use the command system for undo/redo support
|
||||
const command = createRotateCommand([page.id], -90);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
|
||||
|
||||
const handleRotateRight = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Use the command system for undo/redo support
|
||||
const command = createRotateCommand([page.id], 90);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
|
||||
|
||||
const handleDelete = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDeletePage(page.pageNumber);
|
||||
onSetStatus(`Deleted page ${page.pageNumber}`);
|
||||
}, [page.pageNumber, onDeletePage, onSetStatus]);
|
||||
|
||||
const handleSplit = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Create a command to toggle split at this position
|
||||
const command = createSplitCommand(index);
|
||||
onExecuteCommand(command);
|
||||
|
||||
const hasSplit = splitPositions.has(index);
|
||||
const action = hasSplit ? 'removed' : 'added';
|
||||
onSetStatus(`Split marker ${action} after position ${index + 1}`);
|
||||
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
||||
|
||||
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onInsertFiles) {
|
||||
// Open file manager modal with custom handler for page insertion
|
||||
openFilesModal({
|
||||
insertAfterPage: page.pageNumber,
|
||||
customHandler: (files: File[], insertAfterPage?: number) => {
|
||||
if (insertAfterPage !== undefined) {
|
||||
onInsertFiles(files, insertAfterPage);
|
||||
}
|
||||
}
|
||||
});
|
||||
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
||||
} else {
|
||||
// Fallback to normal file handling
|
||||
openFilesModal({ insertAfterPage: page.pageNumber });
|
||||
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
||||
}
|
||||
}, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]);
|
||||
|
||||
// Handle click vs drag differentiation
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
setIsMouseDown(true);
|
||||
setMouseStartPos({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
if (!isMouseDown || !mouseStartPos) {
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate distance moved
|
||||
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// If mouse moved less than 5 pixels, consider it a click (not a drag)
|
||||
if (distance < 5 && !isDragging) {
|
||||
onTogglePage(page.id);
|
||||
}
|
||||
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
id: 'move-left',
|
||||
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Move Left',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
},
|
||||
disabled: index === 0
|
||||
},
|
||||
{
|
||||
id: 'move-right',
|
||||
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Move Right',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
},
|
||||
disabled: index === totalPages - 1
|
||||
},
|
||||
{
|
||||
id: 'rotate-left',
|
||||
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Rotate Left',
|
||||
onClick: handleRotateLeft,
|
||||
},
|
||||
{
|
||||
id: 'rotate-right',
|
||||
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Rotate Right',
|
||||
onClick: handleRotateRight,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
icon: <DeleteIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Delete Page',
|
||||
onClick: handleDelete,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: 'split',
|
||||
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Split After',
|
||||
onClick: handleSplit,
|
||||
hidden: index >= totalPages - 1,
|
||||
},
|
||||
{
|
||||
id: 'insert',
|
||||
icon: <AddIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Insert File After',
|
||||
onClick: handleInsertFileAfter,
|
||||
}
|
||||
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[20rem]
|
||||
flex items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||
`}
|
||||
style={{
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePage(page.id);
|
||||
}}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false}
|
||||
onChange={() => {
|
||||
// Selection is handled by container mouseDown
|
||||
}}
|
||||
size="sm"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{page.isBlankPage ? (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '70%',
|
||||
aspectRatio: getDocumentAspectRatio(),
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: 2
|
||||
}}></div>
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
data-original-rotation={page.rotation}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
transform: `rotate(${page.rotation}deg)`,
|
||||
transition: 'transform 0.3s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text size="lg" c="dimmed">📄</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>Page {page.pageNumber}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text
|
||||
className={styles.pageNumber}
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{
|
||||
color: 'var(--mantine-color-white)', // Use theme token for consistency
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
zIndex: 2,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{page.pageNumber}
|
||||
</Text>
|
||||
|
||||
<HoverActionMenu
|
||||
show={isHovered || isMobile}
|
||||
actions={hoverActions}
|
||||
position="inside"
|
||||
className={styles.pageHoverControls}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageThumbnail;
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
import {
|
||||
appendExpression,
|
||||
insertOperatorSmart,
|
||||
firstNExpression,
|
||||
lastNExpression,
|
||||
everyNthExpression,
|
||||
rangeExpression,
|
||||
LogicalOperator,
|
||||
} from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
|
||||
import SelectPages from '@app/components/pageEditor/bulkSelectionPanel/SelectPages';
|
||||
import OperatorsSection from '@app/components/pageEditor/bulkSelectionPanel/OperatorsSection';
|
||||
|
||||
interface AdvancedSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
onUpdatePagesFromCSV: (override?: string) => void;
|
||||
maxPages: number;
|
||||
advancedOpened?: boolean;
|
||||
}
|
||||
|
||||
const AdvancedSelectionPanel = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
onUpdatePagesFromCSV,
|
||||
maxPages,
|
||||
advancedOpened,
|
||||
}: AdvancedSelectionPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [rangeEnd, setRangeEnd] = useState<number | ''>('');
|
||||
|
||||
const handleRangeEndChange = (val: string | number) => {
|
||||
const next = typeof val === 'number' ? val : '';
|
||||
setRangeEnd(next);
|
||||
};
|
||||
|
||||
// Named validation functions
|
||||
const validatePositiveNumber = (value: number): string | null => {
|
||||
return value <= 0 ? 'Enter a positive number' : null;
|
||||
};
|
||||
|
||||
const validateRangeStart = (start: number): string | null => {
|
||||
if (start <= 0) return 'Values must be positive';
|
||||
if (typeof rangeEnd === 'number' && start > rangeEnd) {
|
||||
return 'From must be less than or equal to To';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Named callback functions
|
||||
const applyExpression = (expr: string) => {
|
||||
const nextInput = appendExpression(csvInput, expr);
|
||||
setCsvInput(nextInput);
|
||||
onUpdatePagesFromCSV(nextInput);
|
||||
};
|
||||
|
||||
const insertOperator = (op: LogicalOperator) => {
|
||||
const next = insertOperatorSmart(csvInput, op);
|
||||
setCsvInput(next);
|
||||
// Trigger visual selection update for 'even' and 'odd' operators
|
||||
if (op === 'even' || op === 'odd') {
|
||||
onUpdatePagesFromCSV(next);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstNApply = (value: number) => {
|
||||
const expr = firstNExpression(value, maxPages);
|
||||
if (expr) applyExpression(expr);
|
||||
};
|
||||
|
||||
const handleLastNApply = (value: number) => {
|
||||
const expr = lastNExpression(value, maxPages);
|
||||
if (expr) applyExpression(expr);
|
||||
};
|
||||
|
||||
const handleEveryNthApply = (value: number) => {
|
||||
const expr = everyNthExpression(value);
|
||||
if (expr) applyExpression(expr);
|
||||
};
|
||||
|
||||
const handleRangeApply = (start: number) => {
|
||||
if (typeof rangeEnd !== 'number') return;
|
||||
const expr = rangeExpression(start, rangeEnd, maxPages);
|
||||
if (expr) applyExpression(expr);
|
||||
setRangeEnd('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Advanced section */}
|
||||
{advancedOpened && (
|
||||
<div className={classes.advancedSection}>
|
||||
<div className={classes.advancedContent}>
|
||||
{/* Cards row */}
|
||||
<Flex direction="row" mb="xs" wrap="wrap">
|
||||
<SelectPages
|
||||
title={t('bulkSelection.firstNPages.title', 'First N Pages')}
|
||||
placeholder={t('bulkSelection.firstNPages.placeholder', 'Number of pages')}
|
||||
onApply={handleFirstNApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validatePositiveNumber}
|
||||
/>
|
||||
|
||||
<SelectPages
|
||||
title={t('bulkSelection.range.title', 'Range')}
|
||||
placeholder={t('bulkSelection.range.fromPlaceholder', 'From')}
|
||||
onApply={handleRangeApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validateRangeStart}
|
||||
isRange={true}
|
||||
rangeEndValue={rangeEnd}
|
||||
onRangeEndChange={handleRangeEndChange}
|
||||
rangeEndPlaceholder={t('bulkSelection.range.toPlaceholder', 'To')}
|
||||
/>
|
||||
|
||||
<SelectPages
|
||||
title={t('bulkSelection.lastNPages.title', 'Last N Pages')}
|
||||
placeholder={t('bulkSelection.lastNPages.placeholder', 'Number of pages')}
|
||||
onApply={handleLastNApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validatePositiveNumber}
|
||||
/>
|
||||
|
||||
<SelectPages
|
||||
title={t('bulkSelection.everyNthPage.title', 'Every Nth Page')}
|
||||
placeholder={t('bulkSelection.everyNthPage.placeholder', 'Step size')}
|
||||
onApply={handleEveryNthApply}
|
||||
maxPages={maxPages}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Operators row at bottom */}
|
||||
<OperatorsSection
|
||||
csvInput={csvInput}
|
||||
onInsertOperator={insertOperator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedSelectionPanel;
|
||||
@@ -0,0 +1,136 @@
|
||||
// Pure helper utilities for the BulkSelectionPanel UI
|
||||
|
||||
export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd';
|
||||
|
||||
// Returns a new CSV expression with expr appended.
|
||||
// If current ends with an operator token, expr is appended directly.
|
||||
// Otherwise, it is joined with " or ".
|
||||
export function appendExpression(currentInput: string, expr: string): string {
|
||||
const current = (currentInput || '').trim();
|
||||
if (!current) return expr;
|
||||
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
|
||||
// Add space if operator doesn't already have one
|
||||
if (endsWithOperator) {
|
||||
const needsSpace = !current.endsWith(' ');
|
||||
return `${current}${needsSpace ? ' ' : ''}${expr}`;
|
||||
}
|
||||
return `${current} or ${expr}`;
|
||||
}
|
||||
|
||||
// Smartly inserts/normalizes a logical operator at the end of the current input.
|
||||
// Produces a trailing space to allow the next token to be typed naturally.
|
||||
export function insertOperatorSmart(currentInput: string, op: LogicalOperator): string {
|
||||
const text = (currentInput || '').trim();
|
||||
// Handle 'even' and 'odd' as page selection expressions, not logical operators
|
||||
if (op === 'even' || op === 'odd') {
|
||||
if (text.length === 0) return `${op} `;
|
||||
// If current input ends with a logical operator, append the page selection with proper spacing
|
||||
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text);
|
||||
if (endsWithOperator) {
|
||||
// Add space if the operator doesn't already have one
|
||||
const needsSpace = !text.endsWith(' ');
|
||||
return `${text}${needsSpace ? ' ' : ''}${op} `;
|
||||
}
|
||||
return `${text} or ${op} `;
|
||||
}
|
||||
|
||||
if (text.length === 0) return `${op} `;
|
||||
|
||||
// Extract up to the last two operator tokens (words or symbols) from the end
|
||||
const tokens: string[] = [];
|
||||
let rest = text;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
|
||||
if (!m || m.index === undefined) break;
|
||||
const raw = m[1].toLowerCase();
|
||||
const word = raw === '&' ? 'and' : raw === '|' || raw === ',' ? 'or' : raw === '!' ? 'not' : raw;
|
||||
tokens.unshift(word);
|
||||
rest = rest.slice(0, m.index).trimEnd();
|
||||
}
|
||||
|
||||
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
|
||||
const click = op; // desired operator
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return emit(text, click);
|
||||
}
|
||||
|
||||
// Normalize to allowed set
|
||||
const phrase = tokens.join(' ');
|
||||
const allowed = new Set(['and', 'or', 'not', 'and not', 'or not']);
|
||||
|
||||
// Helpers for transitions from a single trailing token
|
||||
const fromSingle = (t: string): string => {
|
||||
if (t === 'and') {
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or'; // 'and or' is invalid, so just use 'or'
|
||||
return 'and not';
|
||||
}
|
||||
if (t === 'or') {
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or';
|
||||
return 'or not';
|
||||
}
|
||||
// t === 'not'
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or';
|
||||
return 'not';
|
||||
};
|
||||
|
||||
// From combined phrase
|
||||
const fromCombo = (p: string): string => {
|
||||
if (p === 'and not') {
|
||||
if (click === 'not') return 'and not';
|
||||
if (click === 'and') return 'and';
|
||||
if (click === 'or') return 'or'; // 'and not or' is invalid, so just use 'or'
|
||||
return 'and not';
|
||||
}
|
||||
if (p === 'or not') {
|
||||
if (click === 'not') return 'or not';
|
||||
if (click === 'or') return 'or';
|
||||
if (click === 'and') return 'and'; // 'or not and' is invalid, so just use 'and'
|
||||
return 'or not';
|
||||
}
|
||||
// Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op
|
||||
return click;
|
||||
};
|
||||
|
||||
const base = rest.trim();
|
||||
const nextPhrase = tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
|
||||
if (!allowed.has(nextPhrase)) {
|
||||
return emit(base, click);
|
||||
}
|
||||
return emit(base, nextPhrase);
|
||||
}
|
||||
|
||||
// Expression builders for Advanced actions
|
||||
export function firstNExpression(n: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
|
||||
return `1-${end}`;
|
||||
}
|
||||
|
||||
export function lastNExpression(n: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const count = Math.max(1, Math.floor(n));
|
||||
const start = Math.max(1, maxPages - count + 1);
|
||||
if (maxPages <= 0) return null;
|
||||
return `${start}-${maxPages}`;
|
||||
}
|
||||
|
||||
export function everyNthExpression(n: number): string | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return `${Math.max(1, Math.floor(n))}n`;
|
||||
}
|
||||
|
||||
export function rangeExpression(start: number, end: number, maxPages: number): string | null {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
let s = Math.floor(start);
|
||||
let e = Math.floor(end);
|
||||
if (s > e) [s, e] = [e, s];
|
||||
s = Math.max(1, s);
|
||||
e = maxPages > 0 ? Math.min(maxPages, e) : e;
|
||||
return `${s}-${e}`;
|
||||
}
|
||||
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
.panelGroup {
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
min-width: 24rem;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dropdownContainer {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.menuDropdown {
|
||||
min-width: 22.5rem;
|
||||
}
|
||||
|
||||
.dropdownContent {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.leftCol {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 8rem - 0.75rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rightCol {
|
||||
width: 8rem;
|
||||
border-left: 0.0625rem solid var(--border-default);
|
||||
padding-left: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.operatorGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.operatorChip {
|
||||
width: 100%;
|
||||
border-radius: 1.25rem;
|
||||
border: 0.0625rem solid var(--bulk-card-border);
|
||||
background-color: var(--bulk-card-bg);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.operatorChip:hover:not(:disabled) {
|
||||
border-color: var(--bulk-card-hover-border);
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-0.0625rem);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.operatorChip:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.operatorChip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .operatorChip {
|
||||
background-color: var(--bulk-card-bg);
|
||||
border-color: var(--bulk-card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .operatorChip:hover:not(:disabled) {
|
||||
background-color: var(--hover-bg);
|
||||
border-color: var(--bulk-card-hover-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdownHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 0.0625rem solid var(--border-default);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menuItemRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Icon-based chevrons */
|
||||
.chevronIcon {
|
||||
transition: transform 150ms ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.chevronUp {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
.inlineRow {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.inlineRowCompact {
|
||||
padding: 0.5rem 0.5rem 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.menuItemCloseHover {
|
||||
background-color: var(--text-brand-accent);
|
||||
opacity: 0.1;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .menuItemCloseHover {
|
||||
background-color: var(--text-brand-accent);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.selectedList {
|
||||
max-height: 8rem;
|
||||
overflow: auto;
|
||||
background-color: var(--bg-raised);
|
||||
border: 0.0625rem solid var(--border-default);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
min-width: 24rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selectedText {
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.advancedSection {
|
||||
margin-top: 0.5rem;
|
||||
min-width: 24rem;
|
||||
}
|
||||
|
||||
.advancedHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 0.0625rem solid var(--border-default);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.advancedContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.advancedItem {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.advancedItem:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .advancedItem:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.advancedCard {
|
||||
background-color: var(--bulk-card-bg);
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .advancedCard {
|
||||
background-color: var(--bulk-card-bg);
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fullWidthInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.applyButton {
|
||||
min-width: 4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Style inputs and buttons within advanced cards to match bg-raised */
|
||||
.advancedCard :global(.mantine-NumberInput-input) {
|
||||
background-color: var(--bg-raised) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.advancedCard :global(.mantine-Button-root) {
|
||||
background-color: var(--bg-raised) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.advancedCard :global(.mantine-Button-root:hover) {
|
||||
background-color: var(--hover-bg) !important;
|
||||
border-color: var(--border-strong) !important;
|
||||
}
|
||||
|
||||
/* Error helper text above the input */
|
||||
.errorText {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-brand-accent);
|
||||
}
|
||||
|
||||
/* Dark-mode adjustments */
|
||||
:global([data-mantine-color-scheme='dark']) .selectedList {
|
||||
background-color: var(--bg-raised);
|
||||
}
|
||||
|
||||
/* Small screens: allow the section to shrink instead of enforcing a large min width */
|
||||
@media (max-width: 480px) {
|
||||
.panelGroup,
|
||||
.selectedList,
|
||||
.advancedSection,
|
||||
.panelContainer {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Outermost panel container scrolling */
|
||||
.panelContainer {
|
||||
max-height: 95vh;
|
||||
overflow: auto;
|
||||
background-color: var(--bulk-panel-bg);
|
||||
color: var(--text-primary);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Override Mantine Popover dropdown background */
|
||||
:global(.mantine-Popover-dropdown) {
|
||||
background-color: var(--bulk-panel-bg) !important;
|
||||
border-color: var(--bulk-card-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Override Mantine Switch outline */
|
||||
.advancedSwitch :global(.mantine-Switch-input) {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.advancedSwitch :global(.mantine-Switch-input:focus) {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, Text, Group, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
import { LogicalOperator } from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
|
||||
|
||||
interface OperatorsSectionProps {
|
||||
csvInput: string;
|
||||
onInsertOperator: (op: LogicalOperator) => void;
|
||||
}
|
||||
|
||||
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">{t('bulkSelection.keywords.title', 'Keywords')}:</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('and')}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Combine selections (both conditions must be true)"
|
||||
>
|
||||
<Text size="xs" fw={500}>and</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('or')}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Add to selection (either condition can be true)"
|
||||
>
|
||||
<Text size="xs" fw={500}>or</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('not')}
|
||||
disabled={!csvInput.trim()}
|
||||
title="Exclude from selection"
|
||||
>
|
||||
<Text size="xs" fw={500}>not</Text>
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider my="sm" />
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('even')}
|
||||
title="Select all even-numbered pages (2, 4, 6, 8...)"
|
||||
>
|
||||
<Text size="xs" fw={500}>even</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator('odd')}
|
||||
title="Select all odd-numbered pages (1, 3, 5, 7...)"
|
||||
>
|
||||
<Text size="xs" fw={500}>odd</Text>
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperatorsSection;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TextInput, Button, Text, Flex, Switch } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { usePageSelectionTips } from '@app/components/tooltips/usePageSelectionTips';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
|
||||
interface PageSelectionInputProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
onUpdatePagesFromCSV: (override?: string) => void;
|
||||
onClear: () => void;
|
||||
advancedOpened?: boolean;
|
||||
onToggleAdvanced?: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const PageSelectionInput = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
onUpdatePagesFromCSV,
|
||||
onClear,
|
||||
advancedOpened,
|
||||
onToggleAdvanced,
|
||||
}: PageSelectionInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const pageSelectionTips = usePageSelectionTips();
|
||||
|
||||
return (
|
||||
<div className={classes.panelGroup}>
|
||||
{/* Header row with tooltip/title and advanced toggle */}
|
||||
<Flex justify="space-between" align="center" mb="sm">
|
||||
<Tooltip
|
||||
position="left"
|
||||
offset={20}
|
||||
header={pageSelectionTips.header}
|
||||
portalTarget={document.body}
|
||||
pinOnClick={true}
|
||||
containerStyle={{ marginTop: "1rem"}}
|
||||
tips={pageSelectionTips.tips}
|
||||
>
|
||||
<Flex onClick={(e) => e.stopPropagation()} align="center" gap="xs">
|
||||
<LocalIcon icon="gpp-maybe-outline-rounded" width="1rem" height="1rem" style={{ color: 'var(--text-instruction)' }} />
|
||||
<Text>Page Selection</Text>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
{typeof advancedOpened === 'boolean' && (
|
||||
<Flex align="center" gap="xs">
|
||||
<Text size="sm" c="var(--text-secondary)">{t('bulkSelection.advanced.title', 'Advanced')}</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={!!advancedOpened}
|
||||
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
|
||||
title={t('bulkSelection.advanced.title', 'Advanced')}
|
||||
className={classes.advancedSwitch}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Text input */}
|
||||
<TextInput
|
||||
value={csvInput}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setCsvInput(next);
|
||||
onUpdatePagesFromCSV(next);
|
||||
}}
|
||||
placeholder="1,3,5-10"
|
||||
rightSection={
|
||||
csvInput && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
minWidth: 'auto',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
|
||||
className={classes.textInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageSelectionInput;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Text, NumberInput, Group } from '@mantine/core';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
|
||||
interface SelectPagesProps {
|
||||
title: string;
|
||||
placeholder: string;
|
||||
onApply: (value: number) => void;
|
||||
maxPages: number;
|
||||
validationFn?: (value: number) => string | null;
|
||||
isRange?: boolean;
|
||||
rangeEndValue?: number | '';
|
||||
onRangeEndChange?: (value: string | number) => void;
|
||||
rangeEndPlaceholder?: string;
|
||||
}
|
||||
|
||||
const SelectPages = ({
|
||||
title,
|
||||
placeholder,
|
||||
onApply,
|
||||
validationFn,
|
||||
isRange = false,
|
||||
rangeEndValue,
|
||||
onRangeEndChange,
|
||||
rangeEndPlaceholder,
|
||||
}: SelectPagesProps) => {
|
||||
const [value, setValue] = useState<number | ''>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = (val: string | number) => {
|
||||
const next = typeof val === 'number' ? val : '';
|
||||
setValue(next);
|
||||
|
||||
if (validationFn && typeof next === 'number') {
|
||||
setError(validationFn(next));
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (value === '' || typeof value !== 'number') return;
|
||||
onApply(value);
|
||||
setValue('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const isDisabled = Boolean(error) || value === '';
|
||||
|
||||
return (
|
||||
<div className={classes.advancedCard}>
|
||||
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">{title}</Text>
|
||||
{error && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{error}</Text>)}
|
||||
<div className={classes.inputGroup}>
|
||||
<Group gap="sm" align="flex-end" wrap="nowrap">
|
||||
{isRange ? (
|
||||
<>
|
||||
<div style={{ flex: 1 }}>
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
min={1}
|
||||
placeholder={placeholder}
|
||||
error={Boolean(error)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={rangeEndValue}
|
||||
onChange={onRangeEndChange}
|
||||
min={1}
|
||||
placeholder={rangeEndPlaceholder}
|
||||
error={Boolean(error)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
min={1}
|
||||
placeholder={placeholder}
|
||||
className={classes.fullWidthInput}
|
||||
error={Boolean(error)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className={classes.applyButton}
|
||||
onClick={handleApply}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectPages;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
||||
|
||||
interface SelectedPagesDisplayProps {
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
syntaxError: string | null;
|
||||
}
|
||||
|
||||
const SelectedPagesDisplay = ({
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
syntaxError,
|
||||
}: SelectedPagesDisplayProps) => {
|
||||
if (selectedPageIds.length === 0 && !syntaxError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.selectedList}>
|
||||
{syntaxError ? (
|
||||
<Text size="xs" className={classes.errorText}>{syntaxError}</Text>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" className={classes.selectedText}>
|
||||
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
|
||||
const page = displayDocument.pages.find(p => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(n => n > 0).join(', ') : ''})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedPagesDisplay;
|
||||
@@ -0,0 +1,891 @@
|
||||
import { FileId } from '@app/types/file';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
|
||||
// V1-style DOM-first command system (replaces the old React state commands)
|
||||
export abstract class DOMCommand {
|
||||
abstract execute(): void;
|
||||
abstract undo(): void;
|
||||
abstract description: string;
|
||||
}
|
||||
|
||||
export class RotatePageCommand extends DOMCommand {
|
||||
constructor(
|
||||
private pageId: string,
|
||||
private degrees: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
let newRotation = currentRotation + this.degrees;
|
||||
|
||||
newRotation = ((newRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
let previousRotation = currentRotation - this.degrees;
|
||||
|
||||
previousRotation = ((previousRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${previousRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Rotate page ${this.degrees > 0 ? 'right' : 'left'}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeletePagesCommand extends DOMCommand {
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
private originalSelectedPages: number[] = [];
|
||||
private hasExecuted: boolean = false;
|
||||
private pageIdsToDelete: string[] = [];
|
||||
private onAllPagesDeleted?: () => void;
|
||||
|
||||
constructor(
|
||||
private pagesToDelete: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void,
|
||||
private getSelectedPages: () => number[],
|
||||
onAllPagesDeleted?: () => void
|
||||
) {
|
||||
super();
|
||||
this.onAllPagesDeleted = onAllPagesDeleted;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.pagesToDelete.length === 0) return;
|
||||
|
||||
// Store complete original state for undo (only on first execution)
|
||||
if (!this.hasExecuted) {
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page})) // Deep copy pages
|
||||
};
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
this.originalSelectedPages = [...this.getSelectedPages()];
|
||||
|
||||
// Convert page numbers to page IDs for stable identification
|
||||
this.pageIdsToDelete = this.pagesToDelete.map(pageNum => {
|
||||
const page = currentDoc.pages.find(p => p.pageNumber === pageNum);
|
||||
return page?.id || '';
|
||||
}).filter(id => id);
|
||||
|
||||
this.hasExecuted = true;
|
||||
}
|
||||
|
||||
// Filter out deleted pages by ID (stable across undo/redo)
|
||||
const remainingPages = currentDoc.pages.filter(page =>
|
||||
!this.pageIdsToDelete.includes(page.id)
|
||||
);
|
||||
|
||||
if (remainingPages.length === 0) {
|
||||
// If all pages would be deleted, clear selection/splits and close PDF
|
||||
this.setSelectedPages([]);
|
||||
this.setSplitPositions(new Set());
|
||||
this.onAllPagesDeleted?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Renumber remaining pages
|
||||
remainingPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: remainingPages,
|
||||
totalPages: remainingPages.length,
|
||||
};
|
||||
|
||||
// Adjust split positions
|
||||
const currentSplitPositions = this.getSplitPositions();
|
||||
const newPositions = new Set<number>();
|
||||
currentSplitPositions.forEach(pos => {
|
||||
if (pos < remainingPages.length - 1) {
|
||||
newPositions.add(pos);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply changes
|
||||
this.setDocument(updatedDocument);
|
||||
this.setSelectedPages([]);
|
||||
this.setSplitPositions(newPositions);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
|
||||
// Simply restore the complete original document state
|
||||
this.setDocument(this.originalDocument);
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
this.setSelectedPages(this.originalSelectedPages);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Delete ${this.pagesToDelete.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ReorderPagesCommand extends DOMCommand {
|
||||
private originalPages: PDFPage[] = [];
|
||||
|
||||
constructor(
|
||||
private sourcePageNumber: number,
|
||||
private targetIndex: number,
|
||||
private selectedPages: number[] | undefined,
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc) return;
|
||||
|
||||
// Store original state for undo
|
||||
this.originalPages = currentDoc.pages.map(page => ({...page}));
|
||||
|
||||
// Perform the reorder
|
||||
const sourceIndex = currentDoc.pages.findIndex(p => p.pageNumber === this.sourcePageNumber);
|
||||
if (sourceIndex === -1) return;
|
||||
|
||||
const newPages = [...currentDoc.pages];
|
||||
|
||||
if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) {
|
||||
// Multi-page reorder
|
||||
const selectedPageObjects = this.selectedPages
|
||||
.map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum))
|
||||
.filter(page => page !== undefined) as PDFPage[];
|
||||
|
||||
const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber));
|
||||
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
|
||||
|
||||
remainingPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
|
||||
newPages.splice(0, newPages.length, ...remainingPages);
|
||||
} else {
|
||||
// Single page reorder
|
||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||
newPages.splice(this.targetIndex, 0, movedPage);
|
||||
|
||||
newPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
const reorderedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(reorderedDocument);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.originalPages.length === 0) return;
|
||||
|
||||
// Restore original page order
|
||||
const restoredDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: this.originalPages,
|
||||
totalPages: this.originalPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(restoredDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Reorder page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class SplitCommand extends DOMCommand {
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
|
||||
constructor(
|
||||
private position: number,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original state for undo
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
|
||||
// Toggle the split position
|
||||
const currentPositions = this.getSplitPositions();
|
||||
const newPositions = new Set(currentPositions);
|
||||
|
||||
if (newPositions.has(this.position)) {
|
||||
newPositions.delete(this.position);
|
||||
} else {
|
||||
newPositions.add(this.position);
|
||||
}
|
||||
|
||||
this.setSplitPositions(newPositions);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Restore original split positions
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const currentPositions = this.getSplitPositions();
|
||||
const willAdd = !currentPositions.has(this.position);
|
||||
return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkRotateCommand extends DOMCommand {
|
||||
private originalRotations: Map<string, number> = new Map();
|
||||
|
||||
constructor(
|
||||
private pageIds: string[],
|
||||
private degrees: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.pageIds.forEach(pageId => {
|
||||
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Store original rotation for undo (only on first execution)
|
||||
if (!this.originalRotations.has(pageId)) {
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
this.originalRotations.set(pageId, currentRotation);
|
||||
}
|
||||
|
||||
// Apply rotation using transform to trigger CSS animation
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.pageIds.forEach(pageId => {
|
||||
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img && this.originalRotations.has(pageId)) {
|
||||
img.style.transform = `rotate(${this.originalRotations.get(pageId)}deg)`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Rotate ${this.pageIds.length} page(s) ${this.degrees > 0 ? 'right' : 'left'}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkSplitCommand extends DOMCommand {
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
|
||||
constructor(
|
||||
private positions: number[],
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original state for undo (only on first execution)
|
||||
if (this.originalSplitPositions.size === 0) {
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
}
|
||||
|
||||
// Toggle each position
|
||||
const currentPositions = new Set(this.getSplitPositions());
|
||||
this.positions.forEach(position => {
|
||||
if (currentPositions.has(position)) {
|
||||
currentPositions.delete(position);
|
||||
} else {
|
||||
currentPositions.add(position);
|
||||
}
|
||||
});
|
||||
|
||||
this.setSplitPositions(currentPositions);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Restore original split positions
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Toggle ${this.positions.length} split position(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class SplitAllCommand extends DOMCommand {
|
||||
private originalSplitPositions: Set<number> = new Set();
|
||||
private allPossibleSplits: Set<number> = new Set();
|
||||
|
||||
constructor(
|
||||
private totalPages: number,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void
|
||||
) {
|
||||
super();
|
||||
// Calculate all possible split positions (between pages, not after last page)
|
||||
for (let i = 0; i < this.totalPages - 1; i++) {
|
||||
this.allPossibleSplits.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original state for undo
|
||||
this.originalSplitPositions = new Set(this.getSplitPositions());
|
||||
|
||||
// Check if all splits are already active
|
||||
const currentSplits = this.getSplitPositions();
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
|
||||
|
||||
if (hasAllSplits) {
|
||||
// Remove all splits
|
||||
this.setSplitPositions(new Set());
|
||||
} else {
|
||||
// Add all splits
|
||||
this.setSplitPositions(this.allPossibleSplits);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Restore original split positions
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const currentSplits = this.getSplitPositions();
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
|
||||
return hasAllSplits ? 'Remove all splits' : 'Split all pages';
|
||||
}
|
||||
}
|
||||
|
||||
export class PageBreakCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
|
||||
constructor(
|
||||
private selectedPageNumbers: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.selectedPageNumbers.length === 0) return;
|
||||
|
||||
// Store original state for undo
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page}))
|
||||
};
|
||||
|
||||
// Create new pages array with blank pages inserted
|
||||
const newPages: PDFPage[] = [];
|
||||
this.insertedPages = [];
|
||||
let pageNumberCounter = 1;
|
||||
|
||||
currentDoc.pages.forEach((page, index) => {
|
||||
// Add the current page
|
||||
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
|
||||
newPages.push(updatedPage);
|
||||
|
||||
// If this page is selected for page break insertion, add a blank page after it
|
||||
if (this.selectedPageNumbers.includes(page.pageNumber)) {
|
||||
const blankPage: PDFPage = {
|
||||
id: `blank-${Date.now()}-${index}`,
|
||||
pageNumber: pageNumberCounter++,
|
||||
originalPageNumber: -1, // Mark as blank page
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: true // Custom flag for blank pages
|
||||
};
|
||||
newPages.push(blankPage);
|
||||
this.insertedPages.push(blankPage);
|
||||
}
|
||||
});
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
// No need to maintain selection - page IDs remain stable, so selection persists automatically
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Insert ${this.selectedPageNumbers.length} page break(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkPageBreakCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
private originalSelectedPages: number[] = [];
|
||||
|
||||
constructor(
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSelectedPages: () => number[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc) return;
|
||||
|
||||
// Store original selection to restore later
|
||||
this.originalSelectedPages = this.getSelectedPages();
|
||||
|
||||
// Store original state for undo
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page}))
|
||||
};
|
||||
|
||||
// Create new pages array with blank pages inserted after each page (except the last)
|
||||
const newPages: PDFPage[] = [];
|
||||
this.insertedPages = [];
|
||||
let pageNumberCounter = 1;
|
||||
|
||||
currentDoc.pages.forEach((page, index) => {
|
||||
// Add the current page
|
||||
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
|
||||
newPages.push(updatedPage);
|
||||
|
||||
// Add blank page after each page except the last one
|
||||
if (index < currentDoc.pages.length - 1) {
|
||||
const blankPage: PDFPage = {
|
||||
id: `blank-${Date.now()}-${index}`,
|
||||
pageNumber: pageNumberCounter++,
|
||||
originalPageNumber: -1,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: true
|
||||
};
|
||||
newPages.push(blankPage);
|
||||
this.insertedPages.push(blankPage);
|
||||
}
|
||||
});
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
// Maintain existing selection by mapping original selected pages to their new positions
|
||||
const updatedSelection: number[] = [];
|
||||
this.originalSelectedPages.forEach(originalPageNum => {
|
||||
// Find the original page by matching the page ID from the original document
|
||||
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
|
||||
if (originalPage) {
|
||||
const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage);
|
||||
if (foundPage) {
|
||||
updatedSelection.push(foundPage.pageNumber);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.setSelectedPages(updatedSelection);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Insert page breaks after all pages`;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsertFilesCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
private fileDataMap = new Map<FileId, ArrayBuffer>(); // Store file data for thumbnail generation
|
||||
private originalProcessedFile: any = null; // Store original ProcessedFile for undo
|
||||
private insertedFileMap = new Map<FileId, File>(); // Store inserted files for export
|
||||
|
||||
constructor(
|
||||
private files: File[],
|
||||
private insertAfterPageNumber: number,
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSelectedPages: () => number[],
|
||||
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const currentDoc = this.getCurrentDocument();
|
||||
if (!currentDoc || this.files.length === 0) return;
|
||||
|
||||
// Store original state for undo
|
||||
this.originalDocument = {
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map(page => ({...page}))
|
||||
};
|
||||
|
||||
try {
|
||||
// Process each file to extract pages and wait for all to complete
|
||||
const allNewPages: PDFPage[] = [];
|
||||
|
||||
// Process all files and wait for their completion
|
||||
const baseTimestamp = Date.now();
|
||||
const extractionPromises = this.files.map(async (file, index) => {
|
||||
const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId;
|
||||
// Store inserted file for export
|
||||
this.insertedFileMap.set(fileId, file);
|
||||
// Use base timestamp + index to ensure unique but predictable file IDs
|
||||
return await this.extractPagesFromFile(file, baseTimestamp + index);
|
||||
});
|
||||
|
||||
const extractedPageArrays = await Promise.all(extractionPromises);
|
||||
|
||||
// Flatten all extracted pages
|
||||
for (const pages of extractedPageArrays) {
|
||||
allNewPages.push(...pages);
|
||||
}
|
||||
|
||||
if (allNewPages.length === 0) return;
|
||||
|
||||
// Find insertion point (after the specified page)
|
||||
const insertIndex = this.insertAfterPageNumber; // Insert after page N means insert at index N
|
||||
|
||||
// Create new pages array with inserted pages
|
||||
const newPages: PDFPage[] = [];
|
||||
let pageNumberCounter = 1;
|
||||
|
||||
// Add pages before insertion point
|
||||
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
|
||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
||||
newPages.push(page);
|
||||
}
|
||||
|
||||
// Add inserted pages
|
||||
for (const newPage of allNewPages) {
|
||||
const insertedPage: PDFPage = {
|
||||
...newPage,
|
||||
pageNumber: pageNumberCounter++,
|
||||
selected: false,
|
||||
splitAfter: false
|
||||
};
|
||||
newPages.push(insertedPage);
|
||||
this.insertedPages.push(insertedPage);
|
||||
}
|
||||
|
||||
// Add remaining pages after insertion point
|
||||
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
|
||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
||||
newPages.push(page);
|
||||
}
|
||||
|
||||
// Update document
|
||||
const updatedDocument: PDFDocument = {
|
||||
...currentDoc,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length,
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
// Update FileContext with the new document structure and inserted files
|
||||
if (this.updateFileContext) {
|
||||
this.updateFileContext(updatedDocument, this.insertedFileMap);
|
||||
}
|
||||
|
||||
// Generate thumbnails for inserted pages (all files should be read by now)
|
||||
this.generateThumbnailsForInsertedPages(updatedDocument);
|
||||
|
||||
// Maintain existing selection by mapping original selected pages to their new positions
|
||||
const originalSelection = this.getSelectedPages();
|
||||
const updatedSelection: number[] = [];
|
||||
|
||||
originalSelection.forEach(originalPageNum => {
|
||||
if (originalPageNum <= this.insertAfterPageNumber) {
|
||||
// Pages before insertion point keep same number
|
||||
updatedSelection.push(originalPageNum);
|
||||
} else {
|
||||
// Pages after insertion point are shifted by number of inserted pages
|
||||
updatedSelection.push(originalPageNum + allNewPages.length);
|
||||
}
|
||||
});
|
||||
|
||||
this.setSelectedPages(updatedSelection);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to insert files:', error);
|
||||
// Revert to original state if error occurs
|
||||
if (this.originalDocument) {
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
|
||||
try {
|
||||
const { thumbnailGenerationService } = await import('@app/services/thumbnailGenerationService');
|
||||
|
||||
// Group pages by file ID to generate thumbnails efficiently
|
||||
const pagesByFileId = new Map<FileId, PDFPage[]>();
|
||||
|
||||
for (const page of this.insertedPages) {
|
||||
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */;
|
||||
if (!pagesByFileId.has(fileId)) {
|
||||
pagesByFileId.set(fileId, []);
|
||||
}
|
||||
pagesByFileId.get(fileId)!.push(page);
|
||||
}
|
||||
|
||||
// Generate thumbnails for each file
|
||||
for (const [fileId, pages] of pagesByFileId) {
|
||||
const arrayBuffer = this.fileDataMap.get(fileId);
|
||||
|
||||
console.log('Generating thumbnails for file:', fileId);
|
||||
console.log('Pages:', pages.length);
|
||||
console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
|
||||
|
||||
if (arrayBuffer && arrayBuffer.byteLength > 0) {
|
||||
// Extract page numbers for all pages from this file
|
||||
const pageNumbers = pages.map(page => {
|
||||
const pageNumMatch = page.id.match(/-page-(\d+)$/);
|
||||
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
|
||||
});
|
||||
|
||||
console.log('Generating thumbnails for page numbers:', pageNumbers);
|
||||
|
||||
// Generate thumbnails for all pages from this file at once
|
||||
const results = await thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
arrayBuffer,
|
||||
pageNumbers,
|
||||
{ scale: 0.2, quality: 0.8 }
|
||||
);
|
||||
|
||||
console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
|
||||
|
||||
// Update pages with generated thumbnails
|
||||
for (let i = 0; i < results.length && i < pages.length; i++) {
|
||||
const result = results[i];
|
||||
const page = pages[i];
|
||||
|
||||
if (result.success) {
|
||||
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
|
||||
if (pageIndex >= 0) {
|
||||
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
|
||||
console.log('Updated thumbnail for page:', page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger re-render by updating the document
|
||||
this.setDocument({ ...updatedDocument });
|
||||
} else {
|
||||
console.error('No valid ArrayBuffer found for file ID:', fileId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate thumbnails for inserted pages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async extractPagesFromFile(file: File, baseTimestamp: number): Promise<PDFPage[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
|
||||
|
||||
if (!arrayBuffer) {
|
||||
reject(new Error('Failed to read file'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone the ArrayBuffer before passing to PDF.js (it might consume it)
|
||||
const clonedArrayBuffer = arrayBuffer.slice(0);
|
||||
|
||||
// Use PDF.js via the worker manager to extract pages
|
||||
const { pdfWorkerManager } = await import('@app/services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
const pages: PDFPage[] = [];
|
||||
const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
|
||||
|
||||
console.log('Original ArrayBuffer size:', arrayBuffer.byteLength);
|
||||
console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength);
|
||||
|
||||
// Store the original ArrayBuffer for thumbnail generation
|
||||
this.fileDataMap.set(fileId, arrayBuffer);
|
||||
|
||||
console.log('After storing - fileDataMap size:', this.fileDataMap.size);
|
||||
console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined');
|
||||
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
const pageId = `${fileId}-page-${i}`;
|
||||
pages.push({
|
||||
id: pageId,
|
||||
pageNumber: i, // Will be renumbered in execute()
|
||||
originalPageNumber: i,
|
||||
thumbnail: null, // Will be generated after insertion
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: false
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up PDF document
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
resolve(pages);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalDocument) return;
|
||||
this.setDocument(this.originalDocument);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Insert ${this.files.length} file(s) after page ${this.insertAfterPageNumber}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple undo manager for DOM commands
|
||||
export class UndoManager {
|
||||
private undoStack: DOMCommand[] = [];
|
||||
private redoStack: DOMCommand[] = [];
|
||||
private onStateChange?: () => void;
|
||||
|
||||
setStateChangeCallback(callback: () => void): void {
|
||||
this.onStateChange = callback;
|
||||
}
|
||||
|
||||
executeCommand(command: DOMCommand): void {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
// For async commands that need to be executed manually
|
||||
addToUndoStack(command: DOMCommand): void {
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
undo(): boolean {
|
||||
const command = this.undoStack.pop();
|
||||
if (command) {
|
||||
command.undo();
|
||||
this.redoStack.push(command);
|
||||
this.onStateChange?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
const command = this.redoStack.pop();
|
||||
if (command) {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
this.onStateChange?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.onStateChange?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Shared constants for PageEditor grid layout
|
||||
export const GRID_CONSTANTS = {
|
||||
ITEM_WIDTH: '20rem', // page width
|
||||
ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap
|
||||
ITEM_GAP: '1.5rem', // gap between items
|
||||
OVERSCAN_SMALL: 4, // Overscan for normal documents
|
||||
OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages)
|
||||
} as const;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
export interface PageDocumentHook {
|
||||
document: PDFDocument | null;
|
||||
isVeryLargeDocument: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing PDF document state and metadata in PageEditor
|
||||
* Handles document merging, large document detection, and loading states
|
||||
*/
|
||||
export function usePageDocument(): PageDocumentHook {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
|
||||
// Stable signature for effects (prevents loops)
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get primary file record outside useMemo to track processedFile changes
|
||||
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
||||
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
// Compute merged document with stable signature (prevents infinite loops)
|
||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||
if (activeFileIds.length === 0) return null;
|
||||
|
||||
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
|
||||
|
||||
// If we have file IDs but no file record, something is wrong - return null to show loading
|
||||
if (!primaryStirlingFileStub) {
|
||||
console.log('🎬 PageEditor: No primary file record found, showing loading');
|
||||
return null;
|
||||
}
|
||||
|
||||
const name =
|
||||
activeFileIds.length === 1
|
||||
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||
: activeFileIds
|
||||
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.join(' + ');
|
||||
|
||||
// Build page insertion map from files with insertion positions
|
||||
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
|
||||
const originalFileIds: FileId[] = [];
|
||||
|
||||
activeFileIds.forEach(fileId => {
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (record?.insertAfterPageId !== undefined) {
|
||||
if (!insertionMap.has(record.insertAfterPageId)) {
|
||||
insertionMap.set(record.insertAfterPageId, []);
|
||||
}
|
||||
insertionMap.get(record.insertAfterPageId)!.push(fileId);
|
||||
} else {
|
||||
originalFileIds.push(fileId);
|
||||
}
|
||||
});
|
||||
|
||||
// Build pages by interleaving original pages with insertions
|
||||
let pages: PDFPage[] = [];
|
||||
|
||||
// Helper function to create pages from a file
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processedFile = stirlingFileStub.processedFile;
|
||||
let filePages: PDFPage[] = [];
|
||||
|
||||
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||
// Use fully processed pages with thumbnails
|
||||
filePages = processedFile.pages.map((page, pageIndex) => ({
|
||||
id: `${fileId}-${page.pageNumber}`,
|
||||
pageNumber: startPageNumber + pageIndex,
|
||||
thumbnail: page.thumbnail || null,
|
||||
rotation: page.rotation || 0,
|
||||
selected: false,
|
||||
splitAfter: page.splitAfter || false,
|
||||
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
}));
|
||||
} else if (processedFile?.totalPages) {
|
||||
// Fallback: create pages without thumbnails but with correct count
|
||||
filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
|
||||
id: `${fileId}-${pageIndex + 1}`,
|
||||
pageNumber: startPageNumber + pageIndex,
|
||||
originalPageNumber: pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
rotation: 0,
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return filePages;
|
||||
};
|
||||
|
||||
// Collect all pages from original files (without renumbering yet)
|
||||
const originalFilePages: PDFPage[] = [];
|
||||
originalFileIds.forEach(fileId => {
|
||||
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
|
||||
originalFilePages.push(...filePages);
|
||||
});
|
||||
|
||||
// Start with all original pages numbered sequentially
|
||||
pages = originalFilePages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
// Process each insertion by finding the page ID and inserting after it
|
||||
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
|
||||
const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId);
|
||||
|
||||
if (targetPageIndex === -1) continue;
|
||||
|
||||
// Collect all pages to insert
|
||||
const allNewPages: PDFPage[] = [];
|
||||
fileIds.forEach(fileId => {
|
||||
const insertedPages = createPagesFromFile(fileId, 1);
|
||||
allNewPages.push(...insertedPages);
|
||||
});
|
||||
|
||||
// Insert all new pages after the target page
|
||||
pages.splice(targetPageIndex + 1, 0, ...allNewPages);
|
||||
|
||||
// Renumber all pages after insertion
|
||||
pages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedDoc: PDFDocument = {
|
||||
id: activeFileIds.join('-'),
|
||||
name,
|
||||
file: primaryFile!,
|
||||
pages,
|
||||
totalPages: pages.length,
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false;
|
||||
}, [mergedPdfDocument?.totalPages]);
|
||||
|
||||
// Loading state
|
||||
const isLoading = globalProcessing && !mergedPdfDocument;
|
||||
|
||||
return {
|
||||
document: mergedPdfDocument,
|
||||
isVeryLargeDocument,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface PageEditorState {
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPageIds: string[];
|
||||
|
||||
// Animation state
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
|
||||
// Split state
|
||||
splitPositions: Set<number>;
|
||||
|
||||
// Export state
|
||||
exportLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setSelectionMode: (mode: boolean) => void;
|
||||
setSelectedPageIds: (pages: string[]) => void;
|
||||
setMovingPage: (pageNumber: number | null) => void;
|
||||
setIsAnimating: (animating: boolean) => void;
|
||||
setSplitPositions: (positions: Set<number>) => void;
|
||||
setExportLoading: (loading: boolean) => void;
|
||||
|
||||
// Helper functions
|
||||
togglePage: (pageId: string) => void;
|
||||
toggleSelectAll: (allPageIds: string[]) => void;
|
||||
animateReorder: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing PageEditor UI state
|
||||
* Handles selection, animation, splits, and export states
|
||||
*/
|
||||
export function usePageEditorState(): PageEditorState {
|
||||
// Selection state
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
|
||||
// Animation state
|
||||
const [movingPage, setMovingPage] = useState<number | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Split state - position-based split tracking (replaces page-based splitAfter)
|
||||
const [splitPositions, setSplitPositions] = useState<Set<number>>(new Set());
|
||||
|
||||
// Export state
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
|
||||
// Helper functions
|
||||
const togglePage = useCallback((pageId: string) => {
|
||||
setSelectedPageIds(prev =>
|
||||
prev.includes(pageId)
|
||||
? prev.filter(id => id !== pageId)
|
||||
: [...prev, pageId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback((allPageIds: string[]) => {
|
||||
if (!allPageIds.length) return;
|
||||
|
||||
setSelectedPageIds(prev =>
|
||||
prev.length === allPageIds.length ? [] : allPageIds
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animateReorder = useCallback(() => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => setIsAnimating(false), 500);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
selectionMode,
|
||||
selectedPageIds,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
splitPositions,
|
||||
exportLoading,
|
||||
|
||||
// Setters
|
||||
setSelectionMode,
|
||||
setSelectedPageIds,
|
||||
setMovingPage,
|
||||
setIsAnimating,
|
||||
setSplitPositions,
|
||||
setExportLoading,
|
||||
|
||||
// Helpers
|
||||
togglePage,
|
||||
toggleSelectAll,
|
||||
animateReorder,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import PageSelectByNumberButton from '@app/components/pageEditor/PageSelectByNumberButton';
|
||||
|
||||
interface PageEditorRightRailButtonsParams {
|
||||
totalPages: number;
|
||||
selectedPageCount: number;
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
updatePagesFromCSV: (override?: string) => void;
|
||||
handleSelectAll: () => void;
|
||||
handleDeselectAll: () => void;
|
||||
handleDelete: () => void;
|
||||
onExportSelected: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
}
|
||||
|
||||
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) {
|
||||
const {
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
} = params;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const selectAllLabel = t('rightRail.selectAll', 'Select All');
|
||||
const deselectAllLabel = t('rightRail.deselectAll', 'Deselect All');
|
||||
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
||||
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
||||
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
||||
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'page-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: selectAllLabel,
|
||||
ariaLabel: selectAllLabel,
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
disabled: totalPages === 0 || selectedPageCount === totalPages,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleSelectAll,
|
||||
},
|
||||
{
|
||||
id: 'page-deselect-all',
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: deselectAllLabel,
|
||||
ariaLabel: deselectAllLabel,
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
disabled: selectedPageCount === 0,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleDeselectAll,
|
||||
},
|
||||
{
|
||||
id: 'page-select-by-number',
|
||||
tooltip: selectByNumberLabel,
|
||||
ariaLabel: selectByNumberLabel,
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
disabled: totalPages === 0,
|
||||
visible: totalPages > 0,
|
||||
render: ({ disabled }) => (
|
||||
<PageSelectByNumberButton
|
||||
disabled={disabled}
|
||||
totalPages={totalPages}
|
||||
label={selectByNumberLabel}
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
updatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'page-delete-selected',
|
||||
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: deleteSelectedLabel,
|
||||
ariaLabel: deleteSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 40,
|
||||
disabled: selectedPageCount === 0,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
{
|
||||
id: 'page-export-selected',
|
||||
icon: <LocalIcon icon="download" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: exportSelectedLabel,
|
||||
ariaLabel: exportSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 50,
|
||||
disabled: selectedPageCount === 0 || exportLoading,
|
||||
visible: totalPages > 0,
|
||||
onClick: onExportSelected,
|
||||
},
|
||||
{
|
||||
id: 'page-close-pdf',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: closePdfLabel,
|
||||
ariaLabel: closePdfLabel,
|
||||
section: 'top' as const,
|
||||
order: 60,
|
||||
disabled: activeFileCount === 0,
|
||||
visible: activeFileCount > 0,
|
||||
onClick: closePdf,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
t,
|
||||
selectAllLabel,
|
||||
deselectAllLabel,
|
||||
selectByNumberLabel,
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
]);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface AdminAnalyticsChoiceModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { refetch } = useAppConfig();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChoice = async (enableAnalytics: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enableAnalytics.toString());
|
||||
|
||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
||||
|
||||
// Refetch config to apply new settings without page reload
|
||||
await refetch();
|
||||
|
||||
// Close the modal after successful save
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = () => {
|
||||
handleChoice(true);
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
handleChoice(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}} // Prevent closing
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
zIndex={Z_ANALYTICS_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/analytics-telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{t('analytics.learnMore', 'Learn more')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
>
|
||||
{t('analytics.enable', 'Enable analytics')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleDisable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
>
|
||||
{t('analytics.disable', 'Disable analytics')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
||||
|
||||
interface AllToolsNavButtonProps {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
}
|
||||
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
const { getHomeNavigation } = useSidebarNavigation();
|
||||
|
||||
const handleClick = () => {
|
||||
setActiveButton('tools');
|
||||
// Preserve existing behavior used in QuickAccessBar header
|
||||
handleReaderToggle();
|
||||
handleBackToTools();
|
||||
};
|
||||
|
||||
// Do not highlight All Tools when a specific tool is open (indicator is shown)
|
||||
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
|
||||
|
||||
const navProps = getHomeNavigation();
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent) => {
|
||||
handleUnlessSpecialClick(e, handleClick);
|
||||
};
|
||||
|
||||
const iconNode = (
|
||||
<span className="iconContainer">
|
||||
<AppsIcon sx={{ fontSize: '2rem' }} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={navProps.href}
|
||||
onClick={handleNavClick}
|
||||
size={'lg'}
|
||||
variant="subtle"
|
||||
aria-label={t("quickAccess.allTools", "All Tools")}
|
||||
style={{
|
||||
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
>
|
||||
{iconNode}
|
||||
</ActionIcon>
|
||||
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
|
||||
{t("quickAccess.allTools", "All Tools")}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllToolsNavButton;
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/* AppConfigModal styles */
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 37.5rem; /* 600px */
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 15rem; /* 240px */
|
||||
height: 37.5rem; /* 600px */
|
||||
border-top-left-radius: 0.75rem; /* 12px */
|
||||
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile: compact icon-only navigation */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-container {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 5rem; /* 80px - wider for larger icons */
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item.mobile {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-nav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-nav-section-items {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.625rem; /* 8px 10px */
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
height: 37.5rem; /* 600px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-content-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-content-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import Overview from '@app/components/shared/config/configSections/Overview';
|
||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
sectionTitle: 'var(--modal-nav-section-title)',
|
||||
navItem: 'var(--modal-nav-item)',
|
||||
navItemActive: 'var(--modal-nav-item-active)',
|
||||
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
||||
contentBg: 'var(--modal-content-bg)',
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
|
||||
const handleLogout = () => {
|
||||
// In SaaS this would sign out, in open-source it does nothing
|
||||
console.log('Logout placeholder for SaaS compatibility');
|
||||
};
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return '';
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map(section => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{activeComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppConfigModal;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'colored';
|
||||
color?: string;
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
size = 'sm',
|
||||
variant = 'default',
|
||||
color,
|
||||
textColor,
|
||||
backgroundColor,
|
||||
className,
|
||||
style
|
||||
}) => {
|
||||
const getSizeStyles = () => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return {
|
||||
padding: '0.125rem 0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: '0.5rem',
|
||||
};
|
||||
case 'md':
|
||||
return {
|
||||
padding: '0.25rem 0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: '0.625rem',
|
||||
};
|
||||
case 'lg':
|
||||
return {
|
||||
padding: '0.375rem 1rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: '0.75rem',
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
// If explicit colors are provided, use them
|
||||
if (textColor && backgroundColor) {
|
||||
return {
|
||||
backgroundColor,
|
||||
color: textColor,
|
||||
};
|
||||
}
|
||||
|
||||
// If a single color is provided, use it for text and 20% opacity for background
|
||||
if (color) {
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, ${color} 20%, transparent)`,
|
||||
color: color,
|
||||
};
|
||||
}
|
||||
|
||||
// If variant is colored but no color provided, use default colored styling
|
||||
if (variant === 'colored') {
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`,
|
||||
color: 'var(--category-color-default)',
|
||||
borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`,
|
||||
border: '1px solid',
|
||||
};
|
||||
}
|
||||
|
||||
// Default styling
|
||||
return {
|
||||
background: 'var(--tool-header-badge-bg)',
|
||||
color: 'var(--tool-header-badge-text)',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
style={{
|
||||
...getSizeStyles(),
|
||||
...getVariantStyles(),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import ButtonSelector from '@app/components/shared/ButtonSelector';
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('ButtonSelector', () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render all options as buttons', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
label="Test Label"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should highlight selected button with filled variant', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
label="Selection Label"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const selectedButton = screen.getByRole('button', { name: 'Option 1' });
|
||||
const unselectedButton = screen.getByRole('button', { name: 'Option 2' });
|
||||
|
||||
// Check data-variant attribute for filled/outline
|
||||
expect(selectedButton).toHaveAttribute('data-variant', 'filled');
|
||||
expect(unselectedButton).toHaveAttribute('data-variant', 'outline');
|
||||
expect(screen.getByText('Selection Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call onChange when button is clicked', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('option2');
|
||||
});
|
||||
|
||||
test('should handle undefined value (no selection)', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Both buttons should be outlined when no value is selected
|
||||
const button1 = screen.getByRole('button', { name: 'Option 1' });
|
||||
const button2 = screen.getByRole('button', { name: 'Option 2' });
|
||||
|
||||
expect(button1).toHaveAttribute('data-variant', 'outline');
|
||||
expect(button2).toHaveAttribute('data-variant', 'outline');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
description: 'disable buttons when disabled prop is true',
|
||||
options: [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
],
|
||||
globalDisabled: true,
|
||||
expectedStates: [true, true],
|
||||
},
|
||||
{
|
||||
description: 'disable individual options when option.disabled is true',
|
||||
options: [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2', disabled: true },
|
||||
],
|
||||
globalDisabled: false,
|
||||
expectedStates: [false, true],
|
||||
},
|
||||
])('should $description', ({ options, globalDisabled, expectedStates }) => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
disabled={globalDisabled}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
options.forEach((option, index) => {
|
||||
const button = screen.getByRole('button', { name: option.label });
|
||||
expect(button).toHaveProperty('disabled', expectedStates[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should not call onChange when disabled button is clicked', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2', disabled: true },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not apply fullWidth styling when fullWidth is false', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
fullWidth={false}
|
||||
label="Layout Label"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Option 1' });
|
||||
expect(button).not.toHaveStyle({ flex: '1' });
|
||||
expect(screen.getByText('Layout Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render label element when not provided', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should render buttons
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
|
||||
// Stack should only contain the Group (buttons), no Text element for label
|
||||
const stackElement = container.querySelector('[class*="mantine-Stack-root"]');
|
||||
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import FitText from "@app/components/shared/FitText";
|
||||
|
||||
export interface ButtonOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ButtonSelectorProps<T> {
|
||||
value: T | undefined;
|
||||
onChange: (value: T) => void;
|
||||
options: ButtonOption<T>[];
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
buttonClassName?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const ButtonSelector = <T extends string | number>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label = undefined,
|
||||
disabled = false,
|
||||
fullWidth = true,
|
||||
buttonClassName,
|
||||
textClassName,
|
||||
}: ButtonSelectorProps<T>) => {
|
||||
return (
|
||||
<Stack gap='var(--mantine-spacing-sm)'>
|
||||
{/* Label (if it exists) */}
|
||||
{label && <Text style={{
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
lineHeight: "var(--mantine-line-height-sm)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
}}>{label}</Text>}
|
||||
|
||||
{/* Buttons */}
|
||||
<Group gap='4px'>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={value === option.value ? 'filled' : 'outline'}
|
||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled || option.disabled}
|
||||
className={buttonClassName}
|
||||
style={{
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
height: 'auto',
|
||||
minHeight: '2.5rem',
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
lineHeight: '1.4',
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<FitText
|
||||
text={option.label}
|
||||
lines={1}
|
||||
minimumFontScale={0.5}
|
||||
fontSize={10}
|
||||
className={textClassName}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonSelector;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Stack, Card, Text, Flex } from '@mantine/core';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface CardOption<T = string> {
|
||||
value: T;
|
||||
prefixKey: string;
|
||||
nameKey: string;
|
||||
tooltipKey?: string;
|
||||
tooltipContent?: any[];
|
||||
}
|
||||
|
||||
export interface CardSelectorProps<T, K extends CardOption<T>> {
|
||||
options: K[];
|
||||
onSelect: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
getTooltipContent?: (option: K) => any[];
|
||||
}
|
||||
|
||||
const CardSelector = <T, K extends CardOption<T>>({
|
||||
options,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
getTooltipContent
|
||||
}: CardSelectorProps<T, K>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOptionClick = (value: T) => {
|
||||
if (!disabled) {
|
||||
onSelect(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getTooltips = (option: K) => {
|
||||
if (getTooltipContent) {
|
||||
return getTooltipContent(option);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{options.map((option) => (
|
||||
<Tooltip
|
||||
key={option.value as string}
|
||||
sidebarTooltip
|
||||
tips={getTooltips(option)}
|
||||
>
|
||||
<Card
|
||||
radius="md"
|
||||
w="100%"
|
||||
h={'2.8rem'}
|
||||
style={{
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderColor: 'var(--mantine-color-gray-3)',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-3)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-2)';
|
||||
e.currentTarget.style.transform = 'translateY(0px)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<Flex align={'center'} pl="sm" w="100%">
|
||||
<Text size="sm" c="dimmed" ta="center" fw={350}>
|
||||
{t(option.prefixKey, "Prefix")}
|
||||
</Text>
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
c={undefined}
|
||||
ta="center"
|
||||
style={{ marginLeft: '0.25rem' }}
|
||||
>
|
||||
{t(option.nameKey, "Option Name")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardSelector;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { useFileActions } from '@app/contexts/file/fileHooks';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface DismissAllErrorsButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Check if there are any files in error state
|
||||
const hasErrors = state.ui.errorFileIds.length > 0;
|
||||
|
||||
// Don't render if there are no errors
|
||||
if (!hasErrors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismissAllErrors = () => {
|
||||
actions.clearAllFileErrors();
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className={className}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<CloseIcon fontSize="small" />}
|
||||
onClick={handleDismissAllErrors}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default DismissAllErrorsButton;
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { ReactNode, useState, useMemo } from 'react';
|
||||
import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
|
||||
export interface DropdownItem {
|
||||
value: string;
|
||||
name: string;
|
||||
leftIcon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DropdownListWithFooterProps {
|
||||
// Value and onChange - support both single and multi-select
|
||||
value: string | string[];
|
||||
onChange: (value: string | string[]) => void;
|
||||
|
||||
// Items and display
|
||||
items: DropdownItem[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
// Labels and headers
|
||||
label?: string;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
|
||||
// Behavior
|
||||
multiSelect?: boolean;
|
||||
searchable?: boolean;
|
||||
maxHeight?: number;
|
||||
|
||||
// Styling
|
||||
className?: string;
|
||||
dropdownClassName?: string;
|
||||
|
||||
// Popover props
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
withArrow?: boolean;
|
||||
width?: 'target' | number;
|
||||
}
|
||||
|
||||
const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
placeholder = 'Select option',
|
||||
disabled = false,
|
||||
label,
|
||||
header,
|
||||
footer,
|
||||
multiSelect = false,
|
||||
searchable = false,
|
||||
maxHeight = 300,
|
||||
className = '',
|
||||
dropdownClassName = '',
|
||||
position = 'bottom',
|
||||
withArrow = false,
|
||||
width = 'target'
|
||||
}) => {
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const isMultiValue = Array.isArray(value);
|
||||
const selectedValues = isMultiValue ? value : (value ? [value] : []);
|
||||
|
||||
// Filter items based on search term
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchable || !searchTerm.trim()) {
|
||||
return items;
|
||||
}
|
||||
return items.filter(item =>
|
||||
item.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [items, searchTerm, searchable]);
|
||||
|
||||
const handleItemClick = (itemValue: string) => {
|
||||
if (multiSelect) {
|
||||
const newSelection = selectedValues.includes(itemValue)
|
||||
? selectedValues.filter(v => v !== itemValue)
|
||||
: [...selectedValues, itemValue];
|
||||
onChange(newSelection);
|
||||
} else {
|
||||
onChange(itemValue);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (selectedValues.length === 0) {
|
||||
return placeholder;
|
||||
} else if (selectedValues.length === 1) {
|
||||
const selectedItem = items.find(item => item.value === selectedValues[0]);
|
||||
return selectedItem?.name || selectedValues[0];
|
||||
} else {
|
||||
return `${selectedValues.length} selected`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(event.currentTarget.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={className}>
|
||||
{label && (
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
width={width}
|
||||
position={position}
|
||||
withArrow={withArrow}
|
||||
shadow="md"
|
||||
onClose={() => searchable && setSearchTerm('')}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Box
|
||||
style={{
|
||||
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
minHeight: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<Text size="sm" style={{ flex: 1 }}>
|
||||
{getDisplayText()}
|
||||
</Text>
|
||||
<UnfoldMoreIcon style={{
|
||||
fontSize: '1rem',
|
||||
color: 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2))'
|
||||
}} />
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown className={dropdownClassName}>
|
||||
<Stack gap="xs">
|
||||
{header && (
|
||||
<Box style={{
|
||||
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
|
||||
paddingBottom: '8px'
|
||||
}}>
|
||||
{header}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{searchable && (
|
||||
<Box style={{
|
||||
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
|
||||
paddingBottom: '8px'
|
||||
}}>
|
||||
<TextInput
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
leftSection={<SearchIcon style={{ fontSize: '1rem' }} />}
|
||||
size="sm"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box style={{ maxHeight, overflowY: 'auto' }}>
|
||||
{filteredItems.length === 0 ? (
|
||||
<Box style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{searchable && searchTerm ? 'No results found' : 'No items available'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<Box
|
||||
key={item.value}
|
||||
onClick={() => !item.disabled && handleItemClick(item.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: item.disabled ? 'not-allowed' : 'pointer',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
opacity: item.disabled ? 0.5 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!item.disabled) {
|
||||
e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" style={{ flex: 1 }}>
|
||||
{item.leftIcon && (
|
||||
<Box style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{item.leftIcon}
|
||||
</Box>
|
||||
)}
|
||||
<Text size="sm">{item.name}</Text>
|
||||
</Group>
|
||||
|
||||
{multiSelect && (
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(item.value)}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
size="sm"
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<Box style={{
|
||||
borderTop: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
|
||||
paddingTop: '8px'
|
||||
}}>
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownListWithFooter;
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Text, Button, Stack } from '@mantine/core';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
retry = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
const Fallback = this.props.fallback;
|
||||
return <Fallback error={this.state.error} retry={this.retry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem' }}>
|
||||
<Text size="lg" fw={500} c="red">Something went wrong</Text>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Text size="sm" c="dimmed" style={{ textAlign: 'center', fontFamily: 'monospace' }}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
)}
|
||||
<Button onClick={this.retry} variant="light">
|
||||
Try Again
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from "react";
|
||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
fileStub?: StirlingFileStub;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
||||
}
|
||||
|
||||
const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub);
|
||||
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
p="xs"
|
||||
style={{
|
||||
width: 225,
|
||||
minWidth: 180,
|
||||
maxWidth: 260,
|
||||
cursor: onDoubleClick && isSupported ? "pointer" : undefined,
|
||||
position: 'relative',
|
||||
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined,
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onSelect}
|
||||
data-testid="file-card"
|
||||
data-tour="file-card-checkbox"
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
style={{
|
||||
border: "2px solid #e0e0e0",
|
||||
borderRadius: 8,
|
||||
width: 90,
|
||||
height: 120,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
background: "#fafbfc",
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{/* Hover action buttons */}
|
||||
{isHovered && (onView || onEdit) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 4,
|
||||
padding: 2
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onView && (
|
||||
<Tooltip label="View in Viewer">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onView();
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Tooltip label="Open in File Editor">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{thumb ? (
|
||||
<Image
|
||||
src={thumb}
|
||||
alt="PDF thumbnail"
|
||||
height={110}
|
||||
width={80}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
) : isGenerating ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: '2px solid #ddd',
|
||||
borderTop: '2px solid #666',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: 8
|
||||
}} />
|
||||
<Text size="xs" c="dimmed">Generating...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
{file.size > 100 * 1024 * 1024 && (
|
||||
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" justify="center">
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{getFileSize(file)}
|
||||
</Badge>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{fileStub?.id && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
DB
|
||||
</Badge>
|
||||
)}
|
||||
{!isSupported && (
|
||||
<Badge color="orange" variant="filled" size="sm">
|
||||
{t("fileManager.unsupported", "Unsupported")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
mt={4}
|
||||
>
|
||||
{t("delete", "Remove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileCard;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Menu, Loader, Group, Text } from '@mantine/core';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
|
||||
interface FileDropdownMenuProps {
|
||||
displayName: string;
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
switchingTo?: string | null;
|
||||
viewOptionStyle: React.CSSProperties;
|
||||
pillRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
displayName,
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
}) => {
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu.Target>
|
||||
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} className="ph-no-capture" />
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
backgroundColor: 'var(--right-rail-bg)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{activeFiles.map((file, index) => {
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const isActive = index === currentFileIndex;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={file.fileId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileSelect?.(index);
|
||||
}}
|
||||
className="viewer-file-tab"
|
||||
{...(isActive && { 'data-active': true })}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} className="ph-no-capture" />
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SortIcon from "@mui/icons-material/Sort";
|
||||
import FileCard from "@app/components/shared/FileCard";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { FileId } from "@app/types/file";
|
||||
|
||||
interface FileGridProps {
|
||||
files: Array<{ file: File; record?: StirlingFileStub }>;
|
||||
onRemove?: (index: number) => void;
|
||||
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onSelect?: (fileId: FileId) => void;
|
||||
selectedFiles?: FileId[];
|
||||
showSearch?: boolean;
|
||||
showSort?: boolean;
|
||||
maxDisplay?: number; // If set, shows only this many files with "Show All" option
|
||||
onShowAll?: () => void;
|
||||
showingAll?: boolean;
|
||||
onDeleteAll?: () => void;
|
||||
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
|
||||
}
|
||||
|
||||
type SortOption = 'date' | 'name' | 'size';
|
||||
|
||||
const FileGrid = ({
|
||||
files,
|
||||
onRemove,
|
||||
onDoubleClick,
|
||||
onView,
|
||||
onEdit,
|
||||
onSelect,
|
||||
selectedFiles = [],
|
||||
showSearch = false,
|
||||
showSort = false,
|
||||
maxDisplay,
|
||||
onShowAll,
|
||||
showingAll = false,
|
||||
onDeleteAll,
|
||||
isFileSupported
|
||||
}: FileGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortBy, setSortBy] = useState<SortOption>('date');
|
||||
|
||||
// Filter files based on search term
|
||||
const filteredFiles = files.filter(item =>
|
||||
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort files
|
||||
const sortedFiles = [...filteredFiles].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
|
||||
case 'name':
|
||||
return a.file.name.localeCompare(b.file.name);
|
||||
case 'size':
|
||||
return (b.file.size || 0) - (a.file.size || 0);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply max display limit if specified
|
||||
const displayFiles = maxDisplay && !showingAll
|
||||
? sortedFiles.slice(0, maxDisplay)
|
||||
: sortedFiles;
|
||||
|
||||
const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay;
|
||||
|
||||
return (
|
||||
<Box >
|
||||
{/* Search and Sort Controls */}
|
||||
{(showSearch || showSort || onDeleteAll) && (
|
||||
<Group mb="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="sm">
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
placeholder={t("fileManager.searchFiles", "Search files...")}
|
||||
leftSection={<SearchIcon fontSize="small" />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSort && (
|
||||
<Select
|
||||
data={[
|
||||
{ value: 'date', label: t("fileManager.sortByDate", "Sort by Date") },
|
||||
{ value: 'name', label: t("fileManager.sortByName", "Sort by Name") },
|
||||
{ value: 'size', label: t("fileManager.sortBySize", "Sort by Size") }
|
||||
]}
|
||||
value={sortBy}
|
||||
onChange={(value) => setSortBy(value as SortOption)}
|
||||
leftSection={<SortIcon fontSize="small" />}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{onDeleteAll && (
|
||||
<Button
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={onDeleteAll}
|
||||
>
|
||||
{t("fileManager.deleteAll", "Delete All")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* File Grid */}
|
||||
<Flex
|
||||
direction="row"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
h="30rem"
|
||||
style={{ overflowY: "auto", width: "100%" }}
|
||||
>
|
||||
{displayFiles
|
||||
.filter(item => {
|
||||
if (!item.record?.id) {
|
||||
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((item, idx) => {
|
||||
const fileId = item.record!.id; // Safe to assert after filter
|
||||
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||
return (
|
||||
<FileCard
|
||||
key={fileId + idx}
|
||||
file={item.file}
|
||||
fileStub={item.record}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
||||
onView={onView && supported ? () => onView(item) : undefined}
|
||||
onEdit={onEdit && supported ? () => onEdit(item) : undefined}
|
||||
isSelected={selectedFiles.includes(fileId)}
|
||||
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
|
||||
isSupported={supported}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
{/* Show All Button */}
|
||||
{hasMoreFiles && onShowAll && (
|
||||
<Group justify="center" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={onShowAll}
|
||||
>
|
||||
{t("fileManager.showAll", "Show All")} ({sortedFiles.length} files)
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{displayFiles.length === 0 && (
|
||||
<Box style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Text c="dimmed">
|
||||
{searchTerm
|
||||
? t("fileManager.noFilesFound", "No files found matching your search")
|
||||
: t("fileManager.noFiles", "No files available")
|
||||
}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileGrid;
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Image,
|
||||
Badge,
|
||||
ThemeIcon,
|
||||
SimpleGrid
|
||||
} from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
interface FilePickerModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
storedFiles: any[]; // Files from storage (various formats supported)
|
||||
onSelectFiles: (selectedFiles: File[]) => void;
|
||||
}
|
||||
|
||||
const FilePickerModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
storedFiles,
|
||||
onSelectFiles,
|
||||
}: FilePickerModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
|
||||
// Reset selection when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setSelectedFileIds([]);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const toggleFileSelection = (fileId: FileId) => {
|
||||
setSelectedFileIds(prev => {
|
||||
return prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId];
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
setSelectedFileIds([]);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const selectedFiles = storedFiles.filter(f =>
|
||||
selectedFileIds.includes(f.id)
|
||||
);
|
||||
|
||||
// Convert stored files to File objects
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(async (fileItem) => {
|
||||
try {
|
||||
// If it's already a File object, return as is
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem;
|
||||
}
|
||||
|
||||
// If it has a file property, use that
|
||||
if (fileItem.file && fileItem.file instanceof File) {
|
||||
return fileItem.file;
|
||||
}
|
||||
|
||||
// If it's from IndexedDB storage, reconstruct the File
|
||||
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
|
||||
const arrayBuffer = await fileItem.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
|
||||
return new File([blob], fileItem.name, {
|
||||
type: fileItem.type || 'application/pdf',
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// If it has data property, reconstruct the File
|
||||
if (fileItem.data) {
|
||||
const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' });
|
||||
return new File([blob], fileItem.name, {
|
||||
type: fileItem.type || 'application/pdf',
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
console.warn('Could not convert file item:', fileItem);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error converting file:', error, fileItem);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out any null values and return valid Files
|
||||
const validFiles = convertedFiles.filter((f): f is File => f !== null);
|
||||
|
||||
onSelectFiles(validFiles);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
|
||||
size="lg"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
zIndex={1100}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{storedFiles.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{/* Selection controls */}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{storedFiles.length} {t("fileUpload.filesAvailable", "files available")}
|
||||
{selectedFileIds.length > 0 && (
|
||||
<> • {selectedFileIds.length} selected</>
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={selectAll}>
|
||||
{t("pageEdit.selectAll", "Select All")}
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={selectNone}>
|
||||
{t("pageEdit.deselectAll", "Select None")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* File grid */}
|
||||
<ScrollArea.Autosize mah={400}>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{storedFiles.map((file) => {
|
||||
const fileId = file.id;
|
||||
const isSelected = selectedFileIds.includes(fileId);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={fileId}
|
||||
p="sm"
|
||||
style={{
|
||||
border: isSelected
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: 8,
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onClick={() => toggleFileSelection(fileId)}
|
||||
>
|
||||
<Group gap="sm" align="flex-start">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelection(fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<Box
|
||||
style={{
|
||||
width: 60,
|
||||
height: 80,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{file.thumbnail ? (
|
||||
<Image
|
||||
src={file.thumbnail}
|
||||
alt="PDF thumbnail"
|
||||
height={70}
|
||||
width={50}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size={40}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 24 }} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* File info */}
|
||||
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{formatFileSize(file.size || (file.file?.size || 0))}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{/* Selection summary */}
|
||||
{selectedFileIds.length > 0 && (
|
||||
<Text size="sm" c="blue" ta="center">
|
||||
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={onClose}>
|
||||
{t("close", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedFileIds.length === 0}
|
||||
>
|
||||
{selectedFileIds.length > 0
|
||||
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
|
||||
: t("fileUpload.loadFromStorage", "Load Files")
|
||||
}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePickerModal;
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Box, Center } from '@mantine/core';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
|
||||
import DocumentStack from '@app/components/shared/filePreview/DocumentStack';
|
||||
import HoverOverlay from '@app/components/shared/filePreview/HoverOverlay';
|
||||
import NavigationArrows from '@app/components/shared/filePreview/NavigationArrows';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
// Core file data
|
||||
file: File | StirlingFileStub | null;
|
||||
thumbnail?: string | null;
|
||||
|
||||
// Optional features
|
||||
showStacking?: boolean;
|
||||
showHoverOverlay?: boolean;
|
||||
showNavigation?: boolean;
|
||||
|
||||
// State
|
||||
totalFiles?: number;
|
||||
isAnimating?: boolean;
|
||||
|
||||
// Event handlers
|
||||
onFileClick?: (file: File | StirlingFileStub | null) => void;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||
file,
|
||||
thumbnail,
|
||||
showStacking = false,
|
||||
showHoverOverlay = false,
|
||||
showNavigation = false,
|
||||
totalFiles = 1,
|
||||
isAnimating = false,
|
||||
onFileClick,
|
||||
onPrevious,
|
||||
onNext
|
||||
}) => {
|
||||
if (!file) {
|
||||
return (
|
||||
<Box style={{ width: '100%', height: '100%' }}>
|
||||
<Center style={{ width: '100%', height: '100%' }}>
|
||||
<InsertDriveFileIcon
|
||||
style={{
|
||||
fontSize: '4rem',
|
||||
color: 'var(--mantine-color-gray-4)',
|
||||
opacity: 0.6
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const hasMultipleFiles = totalFiles > 1;
|
||||
|
||||
// Animation styles
|
||||
const animationStyle = isAnimating ? {
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: 'scale(0.95) translateX(1.25rem)',
|
||||
opacity: 0.7
|
||||
} : {};
|
||||
|
||||
// Build the component composition
|
||||
let content = (
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
thumbnail={thumbnail}
|
||||
style={animationStyle}
|
||||
onClick={() => onFileClick?.(file)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wrap with hover overlay if needed
|
||||
if (showHoverOverlay && onFileClick) {
|
||||
content = <HoverOverlay>{content}</HoverOverlay>;
|
||||
}
|
||||
|
||||
// Wrap with document stack if needed
|
||||
if (showStacking) {
|
||||
content = (
|
||||
<DocumentStack totalFiles={totalFiles}>
|
||||
{content}
|
||||
</DocumentStack>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with navigation if needed
|
||||
if (showNavigation && hasMultipleFiles && onPrevious && onNext) {
|
||||
content = (
|
||||
<NavigationArrows
|
||||
onPrevious={onPrevious}
|
||||
onNext={onNext}
|
||||
disabled={isAnimating}
|
||||
>
|
||||
{content}
|
||||
</NavigationArrows>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ width: '100%', height: '100%' }}>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useRef } from "react";
|
||||
import { FileButton, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
file?: File;
|
||||
onChange: (file: File | null) => void;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
variant?: "outline" | "filled" | "light" | "default" | "subtle" | "gradient";
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const FileUploadButton = ({
|
||||
file,
|
||||
onChange,
|
||||
accept,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
variant = "outline",
|
||||
fullWidth = true
|
||||
}: FileUploadButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const resetRef = useRef<() => void>(null);
|
||||
|
||||
const defaultPlaceholder = t('chooseFile', 'Choose File');
|
||||
|
||||
return (
|
||||
<FileButton
|
||||
resetRef={resetRef}
|
||||
onChange={onChange}
|
||||
accept={accept}
|
||||
disabled={disabled}
|
||||
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} variant={variant} fullWidth={fullWidth} color="blue">
|
||||
{file ? file.name : (placeholder || defaultPlaceholder)}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadButton;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { CSSProperties, useMemo, useRef } from 'react';
|
||||
import { useAdjustFontSizeToFit } from '@app/components/shared/fitText/textFit';
|
||||
|
||||
type FitTextProps = {
|
||||
text: string;
|
||||
fontSize?: number; // px; if omitted, uses computed style
|
||||
minimumFontScale?: number; // 0..1
|
||||
lines?: number; // max lines
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
as?: 'span' | 'div';
|
||||
/**
|
||||
* Insert zero-width soft breaks after these characters to prefer wrapping at them
|
||||
* when multi-line is enabled. Defaults to '/'. Ignored when lines === 1.
|
||||
*/
|
||||
softBreakChars?: string | string[];
|
||||
};
|
||||
|
||||
const FitText: React.FC<FitTextProps> = ({
|
||||
text,
|
||||
fontSize,
|
||||
minimumFontScale = 0.8,
|
||||
lines = 1,
|
||||
className,
|
||||
style,
|
||||
as = 'span',
|
||||
softBreakChars = ['-','_','/'],
|
||||
}) => {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Hook runs after mount and on size/text changes; uses observers internally
|
||||
useAdjustFontSizeToFit(ref as any, {
|
||||
maxFontSizePx: fontSize,
|
||||
minFontScale: minimumFontScale,
|
||||
maxLines: lines,
|
||||
singleLine: lines === 1,
|
||||
});
|
||||
|
||||
// Memoize the HTML tag to render (span/div) from the `as` prop so
|
||||
// React doesn't create a new component function on each render.
|
||||
const ElementTag: any = useMemo(() => as, [as]);
|
||||
|
||||
// For the / character, insert zero-width soft breaks to prefer wrapping at them
|
||||
const displayText = useMemo(() => {
|
||||
if (!text) return text;
|
||||
if (!lines || lines <= 1) return text;
|
||||
const chars = Array.isArray(softBreakChars) ? softBreakChars : [softBreakChars];
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(${chars.filter(Boolean).map(esc).join('|')})`, 'g');
|
||||
return text.replace(re, `$1\u200B`);
|
||||
}, [text, lines, softBreakChars]);
|
||||
|
||||
const clampStyles: CSSProperties = {
|
||||
// Multi-line clamp with ellipsis fallback
|
||||
whiteSpace: lines === 1 ? 'nowrap' : 'normal',
|
||||
overflow: 'visible',
|
||||
textOverflow: 'ellipsis',
|
||||
display: lines > 1 ? ('-webkit-box' as any) : undefined,
|
||||
WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined,
|
||||
WebkitLineClamp: lines > 1 ? (lines as any) : undefined,
|
||||
lineClamp: lines > 1 ? (lines as any) : undefined,
|
||||
// Favor shrinking over breaking words; only break at natural spaces or softBreakChars
|
||||
wordBreak: lines > 1 ? ('keep-all' as any) : ('normal' as any),
|
||||
overflowWrap: 'normal',
|
||||
hyphens: 'manual',
|
||||
// fontSize expects rem values (e.g., 1.2, 0.9) to scale with global font size
|
||||
fontSize: fontSize ? `${fontSize}rem` : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<ElementTag ref={ref} className={className} style={{ ...clampStyles, ...style }}>
|
||||
{displayText}
|
||||
</ElementTag>
|
||||
);
|
||||
};
|
||||
|
||||
export default FitText;
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '@app/hooks/useCookieConsent';
|
||||
|
||||
interface FooterProps {
|
||||
privacyPolicy?: string;
|
||||
termsAndConditions?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
analyticsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
privacyPolicy = 'https://www.stirling.com/legal/privacy-policy',
|
||||
termsAndConditions = 'https://www.stirling.com/legal/terms-of-service',
|
||||
accessibilityStatement = 'accessibility',
|
||||
analyticsEnabled = false
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: 'var(--footer-height)',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Flex gap="md"
|
||||
justify="center"
|
||||
align="center"
|
||||
direction="row"
|
||||
style={{ fontSize: '0.75rem' }}>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
|
||||
>
|
||||
{t('survey.nav', 'Survey')}
|
||||
</a>
|
||||
{privacyPolicy && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={privacyPolicy}
|
||||
>
|
||||
{t('legal.privacy', 'Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
{termsAndConditions && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={termsAndConditions}
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
)}
|
||||
{accessibilityStatement && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={accessibilityStatement}
|
||||
>
|
||||
{t('legal.accessibility', 'Accessibility')}
|
||||
</a>
|
||||
)}
|
||||
{analyticsEnabled && (
|
||||
<button
|
||||
className="footer-link px-3"
|
||||
id="cookieBanner"
|
||||
onClick={showCookiePreferences}
|
||||
>
|
||||
{t('legal.showCookieBanner', 'Cookie Preferences')}
|
||||
</button>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Base Hover Menu */
|
||||
.hoverMenu {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: var(--bg-toolbar);
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Inside positioning (Page Editor style) - within container */
|
||||
.inside {
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* Outside positioning (File Editor style) - below container */
|
||||
.outside {
|
||||
bottom: -8px;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import styles from '@app/components/shared/HoverActionMenu.module.css';
|
||||
|
||||
export interface HoverAction {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface HoverActionMenuProps {
|
||||
show: boolean;
|
||||
actions: HoverAction[];
|
||||
position?: 'inside' | 'outside';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
show,
|
||||
actions,
|
||||
position = 'inside',
|
||||
className = ''
|
||||
}) => {
|
||||
const visibleActions = actions.filter(action => !action.hidden);
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{visibleActions.map((action) => (
|
||||
<Tooltip key={action.id} label={action.label}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverActionMenu;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addFiles(files);
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bottom: 0,
|
||||
borderRadius: '0.25rem 0.25rem 0 0',
|
||||
filter: 'var(--drop-shadow-filter)',
|
||||
backgroundColor: 'var(--landing-paper-bg)',
|
||||
transition: 'background-color 0.4s ease',
|
||||
}}
|
||||
activateOnClick={false}
|
||||
styles={{
|
||||
root: {
|
||||
'&[dataAccept]': {
|
||||
backgroundColor: 'var(--landing-drop-paper-bg)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
|
||||
alt="Stirling PDF Logo"
|
||||
style={{
|
||||
height: 'auto',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`min-h-[45vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[30rem] max-w-[calc(100%-2rem)] border transition-all duration-200 dropzone-inner relative`}
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: 'var(--landing-inner-paper-bg)',
|
||||
borderColor: 'var(--landing-inner-paper-border)',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
}}
|
||||
>
|
||||
{/* Logo positioned absolutely in top right corner */}
|
||||
|
||||
|
||||
{/* Centered content container */}
|
||||
<div className="flex flex-col items-center gap-4 flex-none w-full">
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files + Native Upload Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '80%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for native file picker */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</Dropzone>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -0,0 +1,88 @@
|
||||
/* Language selector grid responsive layout */
|
||||
.languageGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.languageItem {
|
||||
border-right: 2px solid var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
.languageItem:nth-child(4n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (max-width: 600px) {
|
||||
.languageGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.languageItem:nth-child(4n) {
|
||||
border-right: 2px solid var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
.languageItem:nth-child(2n) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 601px) and (max-width: 900px) {
|
||||
.languageGrid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.languageItem:nth-child(4n) {
|
||||
border-right: 2px solid var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
.languageItem:nth-child(3n) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
[data-mantine-color-scheme="dark"] .languageItem {
|
||||
border-right-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) {
|
||||
border-right-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) {
|
||||
border-right-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
/* Responsive text visibility */
|
||||
.languageText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.languageText {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple animation */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '@app/i18n';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import styles from '@app/components/shared/LanguageSelector.module.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
// Types
|
||||
interface LanguageSelectorProps {
|
||||
position?: React.ComponentProps<typeof Menu>['position'];
|
||||
offset?: number;
|
||||
compact?: boolean; // icon-only trigger
|
||||
}
|
||||
|
||||
interface LanguageOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface RippleEffect {
|
||||
x: number;
|
||||
y: number;
|
||||
key: number;
|
||||
}
|
||||
|
||||
// Sub-components
|
||||
interface LanguageItemProps {
|
||||
option: LanguageOption;
|
||||
index: number;
|
||||
animationTriggered: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
rippleEffect?: RippleEffect | null;
|
||||
pendingLanguage: string | null;
|
||||
compact: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
option,
|
||||
index,
|
||||
animationTriggered,
|
||||
isSelected,
|
||||
onClick,
|
||||
rippleEffect,
|
||||
pendingLanguage,
|
||||
compact,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label = disabled ? (
|
||||
<Tooltip label={t('comingSoon', 'Coming soon')} position="left" withArrow>
|
||||
<p>{option.label}</p>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p>{option.label}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.languageItem}
|
||||
style={{
|
||||
opacity: animationTriggered ? 1 : 0,
|
||||
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
|
||||
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={disabled ? undefined : onClick}
|
||||
data-selected={isSelected}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '4px',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
||||
: 'transparent',
|
||||
color: disabled
|
||||
? 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))'
|
||||
: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
'&:hover': !disabled ? {
|
||||
backgroundColor: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
|
||||
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
} : {}
|
||||
},
|
||||
label: {
|
||||
fontSize: '13px',
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{!compact && rippleEffect && pendingLanguage === option.value && (
|
||||
<div
|
||||
key={rippleEffect.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: rippleEffect.x,
|
||||
top: rippleEffect.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-blue-4)',
|
||||
opacity: 0.6,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RippleStyles: React.FC = () => (
|
||||
<style>
|
||||
{`
|
||||
@keyframes ripple-expand {
|
||||
0% { width: 0; height: 0; opacity: 0.6; }
|
||||
50% { opacity: 0.3; }
|
||||
100% { width: 100px; height: 100px; opacity: 0; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
);
|
||||
|
||||
// Main component
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
||||
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
|
||||
|
||||
const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||
// Create ripple effect at click position (only for button mode)
|
||||
if (!compact) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
setRippleEffect({ x, y, key: Date.now() });
|
||||
}
|
||||
|
||||
// Start transition animation
|
||||
setPendingLanguage(value);
|
||||
|
||||
// Simulate processing time for smooth transition
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(value);
|
||||
|
||||
setTimeout(() => {
|
||||
setPendingLanguage(null);
|
||||
setOpened(false);
|
||||
|
||||
// Clear ripple effect
|
||||
setTimeout(() => setRippleEffect(null), 100);
|
||||
}, 300);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
|
||||
// Trigger animation when dropdown opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 50);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RippleStyles />
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
position={position}
|
||||
offset={offset}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
{compact ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
title={currentLanguage}
|
||||
className="right-rail-icon"
|
||||
styles={{
|
||||
root: {
|
||||
color: 'var(--right-rail-icon)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
}
|
||||
},
|
||||
label: { fontSize: '12px', fontWeight: 500 }
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
|
||||
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
// Enable languages with >90% translation completion
|
||||
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
|
||||
const isDisabled = !enabledLanguages.includes(option.value);
|
||||
|
||||
return (
|
||||
<LanguageItem
|
||||
key={option.value}
|
||||
option={option}
|
||||
index={index}
|
||||
animationTriggered={animationTriggered}
|
||||
isSelected={option.value === i18n.language}
|
||||
onClick={(event) => handleLanguageChange(option.value, event)}
|
||||
rippleEffect={rippleEffect}
|
||||
pendingLanguage={pendingLanguage}
|
||||
compact={compact}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
export type { LanguageSelectorProps, LanguageOption, RippleEffect };
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Loading fallback component for i18next suspense
|
||||
*/
|
||||
export function LoadingFallback() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
fontSize: "18px",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { addCollection, Icon } from '@iconify/react';
|
||||
import iconSet from '../../../assets/material-symbols-icons.json';
|
||||
|
||||
// Load icons synchronously at import time - guaranteed to be ready on first render
|
||||
let iconsLoaded = false;
|
||||
let localIconCount = 0;
|
||||
|
||||
try {
|
||||
if (iconSet) {
|
||||
addCollection(iconSet);
|
||||
iconsLoaded = true;
|
||||
localIconCount = Object.keys(iconSet.icons || {}).length;
|
||||
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
|
||||
}
|
||||
} catch {
|
||||
console.info('ℹ️ Local icons not available - using CDN fallback');
|
||||
}
|
||||
|
||||
interface LocalIconProps {
|
||||
icon: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalIcon component that uses our locally bundled Material Symbols icons
|
||||
* instead of loading from CDN
|
||||
*/
|
||||
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
|
||||
// Convert our icon naming convention to the local collection format
|
||||
const iconName = icon.startsWith('material-symbols:')
|
||||
? icon
|
||||
: `material-symbols:${icon}`;
|
||||
|
||||
// Development logging (only in dev mode)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const logKey = `icon-${iconName}`;
|
||||
if (!sessionStorage.getItem(logKey)) {
|
||||
const source = iconsLoaded ? 'local' : 'CDN';
|
||||
console.debug(`🎯 Icon: ${iconName} (${source})`);
|
||||
sessionStorage.setItem(logKey, 'logged');
|
||||
}
|
||||
}
|
||||
|
||||
// Always render the icon - Iconify will use local if available, CDN if not
|
||||
return <Icon icon={iconName} {...props} />;
|
||||
};
|
||||
|
||||
export default LocalIcon;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Box, Group, Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MultiSelectControlsProps {
|
||||
selectedCount: number;
|
||||
onClearSelection: () => void;
|
||||
onOpenInFileEditor?: () => void;
|
||||
onOpenInPageEditor?: () => void;
|
||||
onAddToUpload?: () => void;
|
||||
onDeleteAll?: () => void;
|
||||
}
|
||||
|
||||
const MultiSelectControls = ({
|
||||
selectedCount,
|
||||
onClearSelection,
|
||||
onOpenInFileEditor,
|
||||
onOpenInPageEditor,
|
||||
onAddToUpload,
|
||||
onDeleteAll
|
||||
}: MultiSelectControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{selectedCount} {t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
</Button>
|
||||
|
||||
{onAddToUpload && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="green"
|
||||
onClick={onAddToUpload}
|
||||
>
|
||||
{t("fileManager.addToUpload", "Add to Upload")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onOpenInFileEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="orange"
|
||||
onClick={onOpenInFileEditor}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onOpenInPageEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
onClick={onOpenInPageEditor}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
{t("fileManager.openInPageEditor", "Open in Page Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDeleteAll && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={onDeleteAll}
|
||||
>
|
||||
{t("fileManager.deleteAll", "Delete All")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelectControls;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useNavigationGuard } from "@app/contexts/NavigationContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
onExportAndContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
};
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleApplyAndContinue = async () => {
|
||||
if (onApplyAndContinue) {
|
||||
await onApplyAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const _handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
const BUTTON_WIDTH = "10rem";
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={showNavigationWarning}
|
||||
onClose={handleKeepWorking}
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
centered
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Stack>
|
||||
<Stack ta="center" p="md">
|
||||
<Text size="md" fw="300">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
|
||||
</Text>
|
||||
<Text size="lg" fw="500" >
|
||||
{t("areYouSure", "Are you sure you want to leave?")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Desktop layout: 2 groups side by side */}
|
||||
<Group justify="space-between" gap="xl" visibleFrom="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Mobile layout: centered stack of 4 buttons */}
|
||||
<Stack align="center" gap="sm" hiddenFrom="md">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationWarningModal;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import styles from '@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css';
|
||||
|
||||
type ObscuredOverlayProps = {
|
||||
obscured: boolean;
|
||||
overlayMessage?: React.ReactNode;
|
||||
buttonText?: string;
|
||||
onButtonClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
// Optional border radius for the overlay container. If undefined, no radius is applied.
|
||||
borderRadius?: string | number;
|
||||
};
|
||||
|
||||
export default function ObscuredOverlay({
|
||||
obscured,
|
||||
overlayMessage,
|
||||
buttonText,
|
||||
onButtonClick,
|
||||
children,
|
||||
borderRadius,
|
||||
}: ObscuredOverlayProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{children}
|
||||
{obscured && (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
style={{
|
||||
...(borderRadius !== undefined ? { borderRadius } : {}),
|
||||
}}
|
||||
>
|
||||
<div className={styles.overlayContent}>
|
||||
{overlayMessage && (
|
||||
<div className={styles.overlayMessage}>
|
||||
{overlayMessage}
|
||||
</div>
|
||||
)}
|
||||
{buttonText && onButtonClick && (
|
||||
<button type="button" onClick={onButtonClick} className={styles.overlayButton}>
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
background: rgba(16, 18, 27, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.overlayContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overlayMessage {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overlayButton {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
||||
import { ButtonConfig } from '@app/types/sidebar';
|
||||
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
||||
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
getActiveNavButton,
|
||||
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const { startTour } = useOnboarding();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const isOverflow = useIsOverflowing(scrollableRef);
|
||||
|
||||
useEffect(() => {
|
||||
const next = getActiveNavButton(selectedToolKey, readerMode);
|
||||
setActiveButton(next);
|
||||
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode]);
|
||||
|
||||
const handleFilesButtonClick = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
// Helper function to render navigation buttons with URL support
|
||||
const renderNavButton = (config: ButtonConfig, index: number) => {
|
||||
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
|
||||
// Check if this button has URL navigation support
|
||||
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
|
||||
? getToolNavigation(config.id)
|
||||
: null;
|
||||
|
||||
const handleClick = (e?: React.MouseEvent) => {
|
||||
if (navProps && e) {
|
||||
handleUnlessSpecialClick(e, config.onClick);
|
||||
} else {
|
||||
config.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Render navigation button with conditional URL support
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className="flex flex-col items-center gap-1"
|
||||
style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}
|
||||
data-tour={`${config.id}-button`}
|
||||
>
|
||||
<ActionIcon
|
||||
{...(navProps ? {
|
||||
component: "a" as const,
|
||||
href: navProps.href,
|
||||
onClick: (e: React.MouseEvent) => handleClick(e),
|
||||
'aria-label': config.name
|
||||
} : {
|
||||
onClick: () => handleClick(),
|
||||
'aria-label': config.name
|
||||
})}
|
||||
size={isActive ? (config.size || 'lg') : 'lg'}
|
||||
variant="subtle"
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{config.icon}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`button-text ${isActive ? 'active' : 'inactive'}`}>
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mainButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
name: t("quickAccess.read", "Read"),
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
setActiveButton('read');
|
||||
handleReaderToggle();
|
||||
}
|
||||
},
|
||||
// {
|
||||
// id: 'sign',
|
||||
// name: t("quickAccess.sign", "Sign"),
|
||||
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// size: 'lg',
|
||||
// isRound: false,
|
||||
// type: 'navigation',
|
||||
// onClick: () => {
|
||||
// setActiveButton('sign');
|
||||
// handleToolSelect('sign');
|
||||
// }
|
||||
// },
|
||||
{
|
||||
id: 'automate',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
setActiveButton('automate');
|
||||
// If already on automate tool, reset it directly
|
||||
if (selectedToolKey === 'automate') {
|
||||
resetTool('automate');
|
||||
} else {
|
||||
handleToolSelect('automate');
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const middleButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'files',
|
||||
name: t("quickAccess.files", "Files"),
|
||||
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: handleFilesButtonClick
|
||||
},
|
||||
//TODO: Activity
|
||||
//{
|
||||
// id: 'activity',
|
||||
// name: t("quickAccess.activity", "Activity"),
|
||||
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// isRound: true,
|
||||
// size: 'lg',
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
];
|
||||
|
||||
const bottomButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'help',
|
||||
name: t("quickAccess.help", "Help"),
|
||||
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'action',
|
||||
onClick: () => {
|
||||
startTour();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
setConfigModalOpen(true);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="quick-access"
|
||||
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
style={{
|
||||
borderRight: '1px solid var(--border-default)'
|
||||
}}
|
||||
>
|
||||
{/* Fixed header outside scrollable area */}
|
||||
<div className="quick-access-header">
|
||||
<ActiveToolButton activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||
<AllToolsNavButton activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||
|
||||
</div>
|
||||
|
||||
{/* Conditional divider when overflowing */}
|
||||
{isOverflow && (
|
||||
<Divider
|
||||
size="xs"
|
||||
className="overflow-divider"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div
|
||||
ref={scrollableRef}
|
||||
className="quick-access-bar flex-1"
|
||||
onWheel={(e) => {
|
||||
// Prevent the wheel event from bubbling up to parent containers
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="scrollable-content">
|
||||
{/* Main navigation section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{mainButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Divider after main buttons */}
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
|
||||
{/* Middle section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{middleButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Spacer to push bottom buttons to bottom */}
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Bottom section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{bottomButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
QuickAccessBar.displayName = 'QuickAccessBar';
|
||||
|
||||
export default QuickAccessBar;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '@app/hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '@app/theme/mantineTheme';
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import { ToastProvider } from '@app/components/toast';
|
||||
import ToastRenderer from '@app/components/toast/ToastRenderer';
|
||||
import { ToastPortalBinder } from '@app/components/toast';
|
||||
import type { ThemeMode } from '@app/constants/theme';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: ThemeMode;
|
||||
isRainbowMode: boolean;
|
||||
isToggleDisabled: boolean;
|
||||
toggleTheme: () => void;
|
||||
activateRainbow: () => void;
|
||||
deactivateRainbow: () => void;
|
||||
}
|
||||
|
||||
const RainbowThemeContext = createContext<RainbowThemeContextType | null>(null);
|
||||
|
||||
export function useRainbowThemeContext() {
|
||||
const context = useContext(RainbowThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface RainbowThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const rainbowTheme = useRainbowTheme();
|
||||
|
||||
// Determine the Mantine color scheme
|
||||
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
|
||||
|
||||
return (
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
defaultColorScheme={mantineColorScheme}
|
||||
forceColorScheme={mantineColorScheme}
|
||||
>
|
||||
<div
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
<ToastProvider>
|
||||
<ToastPortalBinder />
|
||||
{children}
|
||||
<ToastRenderer />
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ActionIcon, Divider } from '@mantine/core';
|
||||
import '@app/components/shared/rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '@app/contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection } from '@app/contexts/FileContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LanguageSelector from '@app/components/shared/LanguageSelector';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||
|
||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||
|
||||
function renderWithTooltip(
|
||||
node: React.ReactNode,
|
||||
tooltip: React.ReactNode | undefined
|
||||
) {
|
||||
if (!tooltip) return node;
|
||||
|
||||
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} position="left" offset={12} arrow portalTarget={portalTarget}>
|
||||
<div className="right-rail-tooltip-wrapper">{node}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
|
||||
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
|
||||
const { selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
const { signaturesApplied } = useSignature();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
if (currentView === 'pageEditor') return pageEditorTotalPages;
|
||||
return activeFiles.length;
|
||||
}, [currentView, pageEditorTotalPages, activeFiles.length]);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
return pageEditorSelectedCount;
|
||||
}
|
||||
return selectedFileIds.length;
|
||||
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
|
||||
|
||||
const sectionsWithButtons = useMemo(() => {
|
||||
return SECTION_ORDER
|
||||
.map(section => {
|
||||
const sectionButtons = buttons.filter(btn => (btn.section ?? 'top') === section && (btn.visible ?? true));
|
||||
return { section, buttons: sectionButtons };
|
||||
})
|
||||
.filter(entry => entry.buttons.length > 0);
|
||||
}, [buttons]);
|
||||
|
||||
const renderButton = useCallback(
|
||||
(btn: RightRailButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
|
||||
|
||||
const triggerAction = () => {
|
||||
if (!disabled) action?.();
|
||||
};
|
||||
|
||||
if (btn.render) {
|
||||
const context: RightRailRenderContext = {
|
||||
id: btn.id,
|
||||
disabled,
|
||||
allButtonsDisabled,
|
||||
action,
|
||||
triggerAction,
|
||||
};
|
||||
return btn.render(context) ?? null;
|
||||
}
|
||||
|
||||
if (!btn.icon) return null;
|
||||
|
||||
const ariaLabel =
|
||||
btn.ariaLabel || (typeof btn.tooltip === 'string' ? (btn.tooltip as string) : undefined);
|
||||
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
|
||||
const buttonNode = (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className={className}
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
return renderWithTooltip(buttonNode, btn.tooltip);
|
||||
},
|
||||
[actions, allButtonsDisabled, disableForFullscreen]
|
||||
);
|
||||
|
||||
const handleExportAll = useCallback(async () => {
|
||||
if (currentView === 'viewer') {
|
||||
if (!signaturesApplied) {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
viewerContext?.exportActions?.download();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
}, [
|
||||
currentView,
|
||||
selectedFiles,
|
||||
activeFiles,
|
||||
pageEditorFunctions,
|
||||
viewerContext,
|
||||
signaturesApplied
|
||||
]);
|
||||
|
||||
const downloadTooltip = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
return t('rightRail.exportAll', 'Export PDF');
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
return t('rightRail.downloadSelected', 'Download Selected Files');
|
||||
}
|
||||
return t('rightRail.downloadAll', 'Download All');
|
||||
}, [currentView, selectedCount, t]);
|
||||
|
||||
return (
|
||||
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
|
||||
<React.Fragment key={section}>
|
||||
<div className="right-rail-section" data-tour="right-rail-controls">
|
||||
{sectionButtons.map((btn, index) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="right-rail-button-wrapper"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }} data-tour="right-rail-settings">
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>,
|
||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
||||
)}
|
||||
|
||||
{renderWithTooltip(
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
</div>,
|
||||
t('rightRail.language', 'Language')
|
||||
)}
|
||||
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
(currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>,
|
||||
downloadTooltip
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="right-rail-spacer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
|
||||
interface SkeletonLoaderProps {
|
||||
type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer';
|
||||
count?: number;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
type,
|
||||
count = 8,
|
||||
animated = true
|
||||
}) => {
|
||||
const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
|
||||
|
||||
const renderPageGridSkeleton = () => (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
w="100%"
|
||||
h={240}
|
||||
bg="gray.1"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
...animationStyle,
|
||||
animationDelay: animated ? `${i * 0.1}s` : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFileGridSkeleton = () => (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
w="100%"
|
||||
h={280}
|
||||
bg="gray.1"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
...animationStyle,
|
||||
animationDelay: animated ? `${i * 0.1}s` : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderControlsSkeleton = () => (
|
||||
<Group mb="md">
|
||||
<Box w={150} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={120} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={100} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const renderViewerSkeleton = () => (
|
||||
<Stack gap="md" h="100%">
|
||||
{/* Toolbar skeleton */}
|
||||
<Group>
|
||||
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={80} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
</Group>
|
||||
{/* Main content skeleton */}
|
||||
<Box
|
||||
flex={1}
|
||||
bg="gray.1"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
...animationStyle
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'pageGrid':
|
||||
return renderPageGridSkeleton();
|
||||
case 'fileGrid':
|
||||
return renderFileGridSkeleton();
|
||||
case 'controls':
|
||||
return renderControlsSkeleton();
|
||||
case 'viewer':
|
||||
return renderViewerSkeleton();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default SkeletonLoader;
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import styles from '@app/components/shared/textInput/TextInput.module.css';
|
||||
|
||||
/**
|
||||
* Props for the TextInput component
|
||||
*/
|
||||
export interface TextInputProps {
|
||||
/** The input ID (required) */
|
||||
id: string;
|
||||
/** The input name (required) */
|
||||
name: string;
|
||||
/** The input value (required) */
|
||||
value: string;
|
||||
/** Callback when input value changes (required) */
|
||||
onChange: (value: string) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Optional left icon */
|
||||
icon?: React.ReactNode;
|
||||
/** Whether to show the clear button (default: true) */
|
||||
showClearButton?: boolean;
|
||||
/** Custom clear handler (defaults to setting value to empty string) */
|
||||
onClear?: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Additional inline styles */
|
||||
style?: React.CSSProperties;
|
||||
/** HTML autocomplete attribute (default: 'off') */
|
||||
autoComplete?: string;
|
||||
/** Whether the input is disabled (default: false) */
|
||||
disabled?: boolean;
|
||||
/** Whether the input is read-only (default: false) */
|
||||
readOnly?: boolean;
|
||||
/** Accessibility label */
|
||||
'aria-label'?: string;
|
||||
/** Focus event handler */
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
icon,
|
||||
showClearButton = true,
|
||||
onClear,
|
||||
className = '',
|
||||
style,
|
||||
autoComplete = 'off',
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
'aria-label': ariaLabel,
|
||||
onFocus,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const handleClear = () => {
|
||||
if (onClear) {
|
||||
onClear();
|
||||
} else {
|
||||
onChange('');
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowClearButton = showClearButton && value.trim().length > 0 && !disabled && !readOnly;
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${className}`} style={style}>
|
||||
{icon && (
|
||||
<span
|
||||
className={styles.icon}
|
||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
id={id}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
autoComplete={autoComplete}
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
aria-label={ariaLabel}
|
||||
onFocus={onFocus}
|
||||
style={{
|
||||
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
|
||||
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
|
||||
paddingRight: shouldShowClearButton ? '40px' : '12px',
|
||||
paddingLeft: icon ? '40px' : '12px',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{shouldShowClearButton && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={handleClear}
|
||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||
aria-label="Clear input"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Reusable ToolChain component with smart truncation and tooltip expansion
|
||||
* Used across FileListItem, FileDetails, and FileThumbnail for consistent display
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Tooltip, Badge, Group } from '@mantine/core';
|
||||
import { ToolOperation } from '@app/types/file';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
|
||||
interface ToolChainProps {
|
||||
toolChain: ToolOperation[];
|
||||
maxWidth?: string;
|
||||
displayStyle?: 'text' | 'badges' | 'compact';
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const ToolChain: React.FC<ToolChainProps> = ({
|
||||
toolChain,
|
||||
maxWidth = '100%',
|
||||
displayStyle = 'text',
|
||||
size = 'xs',
|
||||
color = 'var(--mantine-color-blue-7)'
|
||||
}) => {
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toolIds = toolChain.map(tool => tool.toolId);
|
||||
|
||||
const getToolName = (toolId: ToolId) => {
|
||||
return t(`home.${toolId}.title`, toolId);
|
||||
};
|
||||
|
||||
// Create full tool chain for tooltip
|
||||
const fullChainDisplay = displayStyle === 'badges' ? (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{toolChain.map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolId}-${index}`}>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{getToolName(tool.toolId)}
|
||||
</Badge>
|
||||
{index < toolChain.length - 1 && (
|
||||
<Text size="sm" c="dimmed">→</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm">{toolIds.map(getToolName).join(' → ')}</Text>
|
||||
);
|
||||
|
||||
// Create truncated display based on available space
|
||||
const getTruncatedDisplay = () => {
|
||||
if (toolIds.length <= 2) {
|
||||
// Show all tools if 2 or fewer
|
||||
return { text: toolIds.map(getToolName).join(' → '), isTruncated: false };
|
||||
} else {
|
||||
// Show first tool ... last tool for longer chains
|
||||
return {
|
||||
text: `${getToolName(toolIds[0])} → +${toolIds.length-2} → ${getToolName(toolIds[toolIds.length - 1])}`,
|
||||
isTruncated: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { text: truncatedText, isTruncated } = getTruncatedDisplay();
|
||||
|
||||
// Compact style for very small spaces
|
||||
if (displayStyle === 'compact') {
|
||||
const compactText = toolIds.length === 1 ? getToolName(toolIds[0]) : `${toolIds.length} tools`;
|
||||
const isCompactTruncated = toolIds.length > 1;
|
||||
|
||||
const compactElement = (
|
||||
<Text
|
||||
size={size}
|
||||
style={{
|
||||
color,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: `${maxWidth}`,
|
||||
cursor: isCompactTruncated ? 'help' : 'default'
|
||||
}}
|
||||
>
|
||||
{compactText}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return isCompactTruncated ? (
|
||||
<Tooltip label={fullChainDisplay} multiline withinPortal>
|
||||
{compactElement}
|
||||
</Tooltip>
|
||||
) : compactElement;
|
||||
}
|
||||
|
||||
// Badge style for file details
|
||||
if (displayStyle === 'badges') {
|
||||
const isBadgesTruncated = toolChain.length > 3;
|
||||
|
||||
const badgesElement = (
|
||||
<div style={{ maxWidth: `${maxWidth}`, overflow: 'hidden' }}>
|
||||
<Group gap="2px" wrap="nowrap">
|
||||
{toolChain.slice(0, 3).map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolId}-${index}`}>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{getToolName(tool.toolId)}
|
||||
</Badge>
|
||||
{index < Math.min(toolChain.length - 1, 2) && (
|
||||
<Text size="xs" c="dimmed">→</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{toolChain.length > 3 && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">...</Text>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{getToolName(toolChain[toolChain.length - 1].toolId)}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
return isBadgesTruncated ? (
|
||||
<Tooltip label={`${toolIds.map(getToolName).join(' → ')}`} withinPortal>
|
||||
{badgesElement}
|
||||
</Tooltip>
|
||||
) : badgesElement;
|
||||
}
|
||||
|
||||
// Text style (default) for file list items
|
||||
const textElement = (
|
||||
<Text
|
||||
size={size}
|
||||
style={{
|
||||
color,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: `${maxWidth}`,
|
||||
cursor: isTruncated ? 'help' : 'default'
|
||||
}}
|
||||
>
|
||||
{truncatedText}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return isTruncated ? (
|
||||
<Tooltip label={fullChainDisplay} withinPortal>
|
||||
{textElement}
|
||||
</Tooltip>
|
||||
) : textElement;
|
||||
};
|
||||
|
||||
export default ToolChain;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
|
||||
interface ToolIconProps {
|
||||
icon: React.ReactNode;
|
||||
opacity?: number;
|
||||
color?: string;
|
||||
marginRight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared icon component for consistent tool icon styling across the application.
|
||||
* Uses the same visual pattern as ToolButton: scaled to 0.8, centered transform, consistent spacing.
|
||||
*/
|
||||
export const ToolIcon: React.FC<ToolIconProps> = ({
|
||||
icon,
|
||||
opacity = 1,
|
||||
color = "var(--tools-text-and-icon-color)",
|
||||
marginRight = "0.5rem"
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="tool-button-icon"
|
||||
style={{
|
||||
color,
|
||||
marginRight,
|
||||
transform: "scale(0.8)",
|
||||
transformOrigin: "center",
|
||||
opacity
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,365 @@
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { addEventListenerWithCleanup } from '@app/utils/genericUtils';
|
||||
import { useTooltipPosition } from '@app/hooks/useTooltipPosition';
|
||||
import { TooltipTip } from '@app/types/tips';
|
||||
import { TooltipContent } from '@app/components/shared/tooltip/TooltipContent';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import styles from '@app/components/shared/tooltip/Tooltip.module.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
export interface TooltipProps {
|
||||
sidebarTooltip?: boolean;
|
||||
position?: 'right' | 'left' | 'top' | 'bottom';
|
||||
content?: React.ReactNode;
|
||||
tips?: TooltipTip[];
|
||||
children: React.ReactElement;
|
||||
offset?: number;
|
||||
maxWidth?: number | string;
|
||||
minWidth?: number | string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
arrow?: boolean;
|
||||
portalTarget?: HTMLElement;
|
||||
header?: { title: string; logo?: React.ReactNode };
|
||||
delay?: number;
|
||||
containerStyle?: React.CSSProperties;
|
||||
pinOnClick?: boolean;
|
||||
/** If true, clicking outside also closes when not pinned (default true) */
|
||||
closeOnOutside?: boolean;
|
||||
/** If true, tooltip interaction is disabled entirely */
|
||||
disabled?: boolean;
|
||||
/** If false, tooltip will not open on focus (hover only) */
|
||||
openOnFocus?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
sidebarTooltip = false,
|
||||
position = 'right',
|
||||
content,
|
||||
tips,
|
||||
children,
|
||||
offset: gap = 8,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
arrow = false,
|
||||
portalTarget,
|
||||
header,
|
||||
delay = 0,
|
||||
containerStyle = {},
|
||||
pinOnClick = false,
|
||||
closeOnOutside = true,
|
||||
disabled = false,
|
||||
openOnFocus = true,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clickPendingRef = useRef(false);
|
||||
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (openTimeoutRef.current) {
|
||||
clearTimeout(openTimeoutRef.current);
|
||||
openTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||
|
||||
const setOpen = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (newOpen === open) return; // avoid churn
|
||||
if (isControlled) onOpenChange?.(newOpen);
|
||||
else setInternalOpen(newOpen);
|
||||
if (!newOpen) setIsPinned(false);
|
||||
},
|
||||
[isControlled, onOpenChange, open]
|
||||
);
|
||||
|
||||
const { coords, positionReady } = useTooltipPosition({
|
||||
open,
|
||||
sidebarTooltip,
|
||||
position,
|
||||
gap,
|
||||
triggerRef,
|
||||
tooltipRef,
|
||||
sidebarRefs: sidebarContext?.sidebarRefs,
|
||||
sidebarState: sidebarContext?.sidebarState,
|
||||
});
|
||||
|
||||
// Close on outside click: pinned → close; not pinned → optionally close
|
||||
const handleDocumentClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const tEl = tooltipRef.current;
|
||||
const trg = triggerRef.current;
|
||||
const target = e.target as Node | null;
|
||||
const insideTooltip = tEl && target && tEl.contains(target);
|
||||
const insideTrigger = trg && target && trg.contains(target);
|
||||
|
||||
// If pinned: only close when clicking outside BOTH tooltip & trigger
|
||||
if (isPinned) {
|
||||
if (!insideTooltip && !insideTrigger) {
|
||||
setIsPinned(false);
|
||||
setOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not pinned and configured to close on outside
|
||||
if (closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[isPinned, closeOnOutside, setOpen]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Attach global click when open (so hover tooltips can also close on outside if desired)
|
||||
if (open || isPinned) {
|
||||
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
|
||||
}
|
||||
}, [open, isPinned, handleDocumentClick]);
|
||||
|
||||
useEffect(() => () => clearTimers(), [clearTimers]);
|
||||
|
||||
const arrowClass = useMemo(() => {
|
||||
if (sidebarTooltip) return null;
|
||||
const map: Record<NonNullable<TooltipProps['position']>, string> = {
|
||||
top: 'tooltip-arrow-bottom',
|
||||
bottom: 'tooltip-arrow-top',
|
||||
left: 'tooltip-arrow-left',
|
||||
right: 'tooltip-arrow-right',
|
||||
};
|
||||
return map[position] || map.right;
|
||||
}, [position, sidebarTooltip]);
|
||||
|
||||
const getArrowStyleClass = useCallback(
|
||||
(key: string) =>
|
||||
styles[key as keyof typeof styles] ||
|
||||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
|
||||
'',
|
||||
[]
|
||||
);
|
||||
|
||||
// === Trigger handlers ===
|
||||
const openWithDelay = useCallback(() => {
|
||||
clearTimers();
|
||||
if (disabled) return;
|
||||
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
|
||||
}, [clearTimers, setOpen, delay, disabled]);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isPinned && !disabled) openWithDelay();
|
||||
(children.props as any)?.onPointerEnter?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props, disabled]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
|
||||
// Moving into the tooltip → keep open
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore transient leave between mousedown and click
|
||||
if (clickPendingRef.current) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
},
|
||||
[clearTimers, isPinned, setOpen, children.props]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
clickPendingRef.current = true;
|
||||
(children.props as any)?.onMouseDown?.(e);
|
||||
},
|
||||
[children.props]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// allow microtask turn so click can see this false
|
||||
queueMicrotask(() => (clickPendingRef.current = false));
|
||||
(children.props as any)?.onMouseUp?.(e);
|
||||
},
|
||||
[children.props]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
clearTimers();
|
||||
if (pinOnClick) {
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (!open) setOpen(true);
|
||||
setIsPinned(true);
|
||||
clickPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
clickPendingRef.current = false;
|
||||
(children.props as any)?.onClick?.(e);
|
||||
},
|
||||
[clearTimers, pinOnClick, open, setOpen, children.props]
|
||||
);
|
||||
|
||||
// Keyboard / focus accessibility
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
if (!isPinned && !disabled && openOnFocus) openWithDelay();
|
||||
(children.props as any)?.onFocus?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
return;
|
||||
}
|
||||
if (!isPinned) setOpen(false);
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
},
|
||||
[isPinned, setOpen, children.props]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
|
||||
const handleTooltipPointerEnter = useCallback(() => {
|
||||
clearTimers();
|
||||
}, [clearTimers]);
|
||||
|
||||
const handleTooltipPointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
);
|
||||
|
||||
// Enhance child with handlers and ref
|
||||
const childWithHandlers = React.cloneElement(children as any, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node || null;
|
||||
const originalRef = (children as any).ref;
|
||||
if (typeof originalRef === 'function') originalRef(node);
|
||||
else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node;
|
||||
},
|
||||
'aria-describedby': open ? tooltipIdRef.current : undefined,
|
||||
onPointerEnter: handlePointerEnter,
|
||||
onPointerLeave: handlePointerLeave,
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseUp: handleMouseUp,
|
||||
onClick: handleClick,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
});
|
||||
|
||||
const shouldShowTooltip = open;
|
||||
|
||||
const tooltipElement = shouldShowTooltip ? (
|
||||
<div
|
||||
id={tooltipIdRef.current}
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
tabIndex={-1}
|
||||
onPointerEnter={handleTooltipPointerEnter}
|
||||
onPointerLeave={handleTooltipPointerLeave}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
|
||||
minWidth,
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
visibility: positionReady ? 'visible' : 'hidden',
|
||||
opacity: positionReady ? 1 : 0,
|
||||
color: 'var(--text-primary)',
|
||||
...containerStyle,
|
||||
}}
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
|
||||
>
|
||||
{isPinned && (
|
||||
<button
|
||||
className={styles['tooltip-pin-button']}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsPinned(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Close tooltip"
|
||||
aria-label="Close tooltip"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
)}
|
||||
{arrow && !sidebarTooltip && (
|
||||
<div
|
||||
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
|
||||
style={
|
||||
coords.arrowOffset !== null
|
||||
? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{header && (
|
||||
<div className={styles['tooltip-header']}>
|
||||
<div className={styles['tooltip-logo']}>
|
||||
{header.logo || (
|
||||
<img
|
||||
src={`${BASE_PATH}/logo-tooltip.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<TooltipContent content={content} tips={tips} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{childWithHandlers}
|
||||
{(() => {
|
||||
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
|
||||
const target = portalTarget ?? defaultTarget;
|
||||
return tooltipElement && target
|
||||
? createPortal(tooltipElement, target)
|
||||
: tooltipElement;
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
|
||||
|
||||
|
||||
const viewOptionStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
paddingTop: '0.3rem',
|
||||
};
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
const createViewOptions = (
|
||||
currentView: WorkbenchType,
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showDropdown = isInViewer && hasMultipleFiles;
|
||||
|
||||
const viewerOption = {
|
||||
label: showDropdown ? (
|
||||
<FileDropdownMenu
|
||||
displayName={displayName}
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
switchingTo={switchingTo}
|
||||
viewOptionStyle={viewOptionStyle}
|
||||
/>
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
<span className="ph-no-capture">{displayName}</span>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
};
|
||||
|
||||
const pageEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
value: "pageEditor",
|
||||
};
|
||||
|
||||
const fileEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
value: "fileEditor",
|
||||
};
|
||||
|
||||
const baseOptions = [
|
||||
viewerOption,
|
||||
pageEditorOption,
|
||||
fileEditorOption,
|
||||
];
|
||||
|
||||
const customOptions = (customViews ?? [])
|
||||
.filter((view) => view.data != null)
|
||||
.map((view) => ({
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
),
|
||||
value: view.workbenchId,
|
||||
}));
|
||||
|
||||
return [...baseOptions, ...customOptions];
|
||||
};
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
customViews?: CustomWorkbenchViewInstance[];
|
||||
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex?: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
customViews = [],
|
||||
activeFiles = [],
|
||||
currentFileIndex = 0,
|
||||
onFileSelect,
|
||||
}: TopControlsProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workbench = view;
|
||||
|
||||
// Show immediate feedback
|
||||
setSwitchingTo(workbench);
|
||||
|
||||
// Defer the heavy view change to next frame so spinner can render
|
||||
requestAnimationFrame(() => {
|
||||
// Give the spinner one more frame to show
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentView(workbench);
|
||||
|
||||
// Clear the loading state after view change completes
|
||||
setTimeout(() => setSwitchingTo(null), 300);
|
||||
});
|
||||
});
|
||||
}, [setCurrentView]);
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<SegmentedControl
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
fullWidth
|
||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||
style={{
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: switchingTo ? 0.8 : 1,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2.6rem',
|
||||
},
|
||||
control: {
|
||||
borderRadius: 9999,
|
||||
},
|
||||
indicator: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2rem',
|
||||
},
|
||||
label: {
|
||||
paddingTop: '0rem',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopControls;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function OverviewHeader() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('config.overview.title', 'Application Configuration')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('config.overview.description', 'Current application settings and configuration details.')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
|
||||
export interface ConfigColors {
|
||||
navBg: string;
|
||||
sectionTitle: string;
|
||||
navItem: string;
|
||||
navItemActive: string;
|
||||
navItemActiveBg: string;
|
||||
contentBg: string;
|
||||
headerBorder: string;
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||
onLogoutClick: () => void,
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
icon: 'person-rounded',
|
||||
component: <Overview onLogoutClick={onLogoutClick} />
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
icon: 'settings-rounded',
|
||||
component: <GeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'hotkeys',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'keyboard-rounded',
|
||||
component: <HotkeysSection />
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user