mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-15 05:02:13 +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,143 @@
|
||||
# RightRail Component
|
||||
|
||||
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Top Section**: Dynamic buttons from tools (empty when none)
|
||||
- **Middle Section**: Grid, Cut, Undo, Redo
|
||||
- **Bottom Section**: Save, Print, Share
|
||||
|
||||
## Usage
|
||||
|
||||
### For Tools (Recommended)
|
||||
|
||||
```tsx
|
||||
import { useRightRailButtons } from '../hooks/useRightRailButtons';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
|
||||
function MyTool() {
|
||||
const handleAction = useCallback(() => {
|
||||
// Your action here
|
||||
}, []);
|
||||
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'my-action',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Execute Action',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>My Tool</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'primary',
|
||||
icon: <StarIcon />,
|
||||
tooltip: 'Primary Action',
|
||||
order: 1,
|
||||
onClick: handlePrimary,
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
icon: <SettingsIcon />,
|
||||
tooltip: 'Secondary Action',
|
||||
order: 2,
|
||||
onClick: handleSecondary,
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
### Conditional Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
// Always show
|
||||
{
|
||||
id: 'process',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Process',
|
||||
disabled: isProcessing,
|
||||
onClick: handleProcess,
|
||||
},
|
||||
// Only show when condition met
|
||||
...(hasResults ? [{
|
||||
id: 'export',
|
||||
icon: <DownloadIcon />,
|
||||
tooltip: 'Export',
|
||||
onClick: handleExport,
|
||||
}] : []),
|
||||
]);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Button Config
|
||||
|
||||
```typescript
|
||||
interface RightRailButtonWithAction {
|
||||
id: string; // Unique identifier
|
||||
icon?: React.ReactNode; // Icon component (omit when using render)
|
||||
tooltip?: React.ReactNode; // Hover tooltip / description
|
||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||
order?: number; // Sort order (default: 0)
|
||||
disabled?: boolean; // Disabled state (default: false)
|
||||
visible?: boolean; // Visibility (default: true)
|
||||
render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer
|
||||
onClick?: () => void; // Click handler (optional if using render)
|
||||
}
|
||||
|
||||
interface RightRailRenderContext {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
allButtonsDisabled: boolean;
|
||||
action?: () => void;
|
||||
triggerAction: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Rendering (Popovers, Multi-button Blocks)
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: t('rightRail.search', 'Search PDF'),
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')}>
|
||||
<Popover position="left">
|
||||
<Popover.Target>
|
||||
<ActionIcon disabled={disabled}>
|
||||
<SearchIcon />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<SearchInterface />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## Built-in Features
|
||||
|
||||
- **Undo/Redo**: Automatically integrates with Page Editor
|
||||
- **Theme Support**: Light/dark mode with CSS variables
|
||||
- **Auto Cleanup**: Buttons unregister when tool unmounts
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
|
||||
- Choose appropriate Material-UI icons
|
||||
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
|
||||
- Use `useCallback` for click handlers to prevent re-registration
|
||||
- Reach for `render` when you need popovers or multi-control groups inside the rail
|
||||
@@ -0,0 +1,161 @@
|
||||
.right-rail {
|
||||
background-color: var(--right-rail-bg);
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
max-width: 3.5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.right-rail-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.right-rail-section {
|
||||
background-color: var(--right-rail-foreground);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.right-rail-button-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
animation: rightRailButtonReveal 200ms ease forwards;
|
||||
transform-origin: top center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.right-rail-tooltip-wrapper {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes rightRailButtonReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.6) translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scaleY(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.right-rail-divider {
|
||||
width: 2.75rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--tool-subcategory-rule-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.right-rail-icon {
|
||||
color: var(--right-rail-icon);
|
||||
}
|
||||
|
||||
.right-rail-icon[aria-disabled="true"],
|
||||
.right-rail-icon[disabled] {
|
||||
color: var(--right-rail-icon-disabled) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* When all buttons are disabled via context */
|
||||
.right-rail--all-disabled .right-rail-icon {
|
||||
color: var(--right-rail-icon-disabled) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.right-rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
|
||||
.right-rail-slot {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 450ms ease-out, opacity 300ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-enter {
|
||||
animation: rightRailGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-exit {
|
||||
animation: rightRailShrinkUp 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-slot.visible {
|
||||
max-height: 40rem; /* increased to fit additional controls + divider */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes rightRailGrowDown {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rightRailShrinkUp {
|
||||
0% {
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove bottom margin from close icon */
|
||||
.right-rail-slot .right-rail-icon {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Inline appear/disappear animation for page-number selector button */
|
||||
.right-rail-fade {
|
||||
transition-property: opacity, transform, max-height, visibility;
|
||||
transition-duration: 220ms, 220ms, 220ms, 0s;
|
||||
transition-timing-function: ease, ease, ease, linear;
|
||||
transition-delay: 0s, 0s, 0s, 0s;
|
||||
transform-origin: top center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-rail-fade.enter {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
max-height: 3rem;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.right-rail-fade.exit {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
max-height: 0;
|
||||
visibility: hidden;
|
||||
/* delay visibility change so opacity/max-height can finish */
|
||||
transition-delay: 0s, 0s, 0s, 220ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { useFileState, useFileContext } from '@app/contexts/FileContext';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createProcessedFile } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
|
||||
interface ViewerAnnotationControlsProps {
|
||||
currentView: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
const { signatureApiRef, isPlacementMode } = useSignature();
|
||||
|
||||
// File state for save functionality
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Check if we're in sign mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignMode = selectedTool === 'sign';
|
||||
|
||||
// Turn off annotation mode when switching away from viewer
|
||||
useEffect(() => {
|
||||
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
}
|
||||
}, [currentView, viewerContext]);
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Mode Toggle with Drawing Controls */}
|
||||
{viewerContext?.isAnnotationMode ? (
|
||||
// When active: Show color picker on hover
|
||||
<div
|
||||
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
|
||||
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<Popover
|
||||
opened={isHoverColorPickerOpen}
|
||||
onClose={() => setIsHoverColorPickerOpen(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '8rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
size={32}
|
||||
onClick={() => {
|
||||
setIsHoverColorPickerOpen(false); // Close hover picker
|
||||
setIsColorPickerOpen(true); // Open main color picker modal
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
// When inactive: Show "Draw" tooltip
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
// Activate ink drawing tool when entering annotation mode
|
||||
if (signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Save PDF with Annotations */}
|
||||
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={async () => {
|
||||
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
|
||||
try {
|
||||
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
|
||||
if (pdfArrayBuffer) {
|
||||
// Create new File object with flattened annotations
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
// Get the original file name or use a default
|
||||
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
|
||||
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
|
||||
|
||||
// Replace the current file in context with the saved version (exact same logic as Sign tool)
|
||||
if (activeFiles.length > 0) {
|
||||
// Generate thumbnail and metadata for the saved file
|
||||
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
|
||||
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
|
||||
|
||||
// Get current file info
|
||||
const currentFileIds = state.files.ids;
|
||||
if (currentFileIds.length > 0) {
|
||||
const currentFileId = currentFileIds[0];
|
||||
const currentRecord = selectors.getStirlingFileStub(currentFileId);
|
||||
|
||||
if (!currentRecord) {
|
||||
console.error('No file record found for:', currentFileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create output stub and file (exact same as Sign tool)
|
||||
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
|
||||
|
||||
// Replace the original file with the saved version
|
||||
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving PDF:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={(color) => {
|
||||
setSelectedColor(color);
|
||||
// Update drawing tool color if annotation mode is active
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.updateDrawSettings(color, 2);
|
||||
} catch (error) {
|
||||
console.log('Unable to update drawing settings:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Choose Drawing Color"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user