mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Feature/v2/selected pageeditor rework (#4756)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <jbrunton96@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
d06391a927
commit
aa20dbb7a6
58
frontend/package-lock.json
generated
58
frontend/package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@embedpdf/core": "^1.4.1",
|
"@embedpdf/core": "^1.4.1",
|
||||||
"@embedpdf/engines": "^1.4.1",
|
"@embedpdf/engines": "^1.4.1",
|
||||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||||
@ -505,6 +506,63 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/@embedpdf/core": {
|
"node_modules/@embedpdf/core": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
"proxy": "http://localhost:8080",
|
"proxy": "http://localhost:8080",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@embedpdf/core": "^1.4.1",
|
"@embedpdf/core": "^1.4.1",
|
||||||
"@embedpdf/engines": "^1.4.1",
|
"@embedpdf/engines": "^1.4.1",
|
||||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||||
|
|||||||
@ -1011,7 +1011,8 @@
|
|||||||
"title": "Choose Your Split Method"
|
"title": "Choose Your Split Method"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectMethod": "Select a split method"
|
"selectMethod": "Select a split method",
|
||||||
|
"resultsTitle": "Split Results"
|
||||||
},
|
},
|
||||||
"rotate": {
|
"rotate": {
|
||||||
"title": "Rotate PDF",
|
"title": "Rotate PDF",
|
||||||
@ -3605,7 +3606,8 @@
|
|||||||
"toggleAnnotations": "Toggle Annotations Visibility",
|
"toggleAnnotations": "Toggle Annotations Visibility",
|
||||||
"annotationMode": "Toggle Annotation Mode",
|
"annotationMode": "Toggle Annotation Mode",
|
||||||
"draw": "Draw",
|
"draw": "Draw",
|
||||||
"save": "Save"
|
"save": "Save",
|
||||||
|
"saveChanges": "Save Changes"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"title": "Search PDF",
|
"title": "Search PDF",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { SignatureProvider } from "@app/contexts/SignatureContext";
|
|||||||
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||||
|
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||||
@ -64,15 +65,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<HotkeyProvider>
|
<HotkeyProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<ViewerProvider>
|
<ViewerProvider>
|
||||||
<SignatureProvider>
|
<PageEditorProvider>
|
||||||
<RightRailProvider>
|
<SignatureProvider>
|
||||||
<TourOrchestrationProvider>
|
<RightRailProvider>
|
||||||
<AdminTourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
{children}
|
<AdminTourOrchestrationProvider>
|
||||||
</AdminTourOrchestrationProvider>
|
{children}
|
||||||
</TourOrchestrationProvider>
|
</AdminTourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</TourOrchestrationProvider>
|
||||||
</SignatureProvider>
|
</RightRailProvider>
|
||||||
|
</SignatureProvider>
|
||||||
|
</PageEditorProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</HotkeyProvider>
|
</HotkeyProvider>
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
.workbench-scrollable {
|
.workbenchScrollable {
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-scrollable::-webkit-scrollbar {
|
.workbenchScrollable::-webkit-scrollbar {
|
||||||
width: 0.375rem;
|
width: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-scrollable::-webkit-scrollbar-track {
|
.workbenchScrollable::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-scrollable::-webkit-scrollbar-thumb {
|
.workbenchScrollable::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--mantine-color-gray-4);
|
background-color: var(--mantine-color-gray-4);
|
||||||
border-radius: 0.1875rem;
|
border-radius: 0.1875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-scrollable::-webkit-scrollbar-thumb:hover {
|
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: var(--mantine-color-gray-5);
|
background-color: var(--mantine-color-gray-5);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
@ -187,18 +186,16 @@ export default function Workbench() {
|
|||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<Box
|
<Box
|
||||||
className={`flex-1 min-h-0 relative z-10 ${currentView === 'viewer' || !isBaseWorkbench(currentView) ? '' : styles.workbenchScrollable}`}
|
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||||
style={{
|
style={{
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
|
||||||
overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Footer
|
<Footer
|
||||||
analyticsEnabled={config?.enableAnalytics ?? undefined}
|
analyticsEnabled={config?.enableAnalytics === true}
|
||||||
termsAndConditions={config?.termsAndConditions}
|
termsAndConditions={config?.termsAndConditions}
|
||||||
privacyPolicy={config?.privacyPolicy}
|
privacyPolicy={config?.privacyPolicy}
|
||||||
cookiePolicy={config?.cookiePolicy}
|
cookiePolicy={config?.cookiePolicy}
|
||||||
|
|||||||
@ -0,0 +1,72 @@
|
|||||||
|
.gridContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtualRows {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtualRow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectionBox {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px dashed #3b82f6;
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropIndicator {
|
||||||
|
position: absolute;
|
||||||
|
width: 4px;
|
||||||
|
background-color: rgba(96, 165, 250, 0.8);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragOverlay {
|
||||||
|
position: relative;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragOverlayBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragOverlayPreview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
@ -1,33 +1,369 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
||||||
|
import styles from '@app/components/pageEditor/DragDropGrid.module.css';
|
||||||
|
import {
|
||||||
|
Z_INDEX_SELECTION_BOX,
|
||||||
|
Z_INDEX_DROP_INDICATOR,
|
||||||
|
Z_INDEX_DRAG_BADGE,
|
||||||
|
} from '@app/styles/zIndex';
|
||||||
|
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
DragOverlay,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
|
||||||
interface DragDropItem {
|
interface DragDropItem {
|
||||||
id: string;
|
id: string;
|
||||||
splitAfter?: boolean;
|
splitAfter?: boolean;
|
||||||
|
isPlaceholder?: boolean;
|
||||||
|
originalFileId?: string;
|
||||||
|
pageNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragDropGridProps<T extends DragDropItem> {
|
interface DragDropGridProps<T extends DragDropItem> {
|
||||||
items: T[];
|
items: T[];
|
||||||
selectedItems: string[];
|
|
||||||
selectionMode: boolean;
|
|
||||||
isAnimating: boolean;
|
|
||||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
||||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||||
|
zoomLevel?: number;
|
||||||
|
selectedFileIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DropSide = 'left' | 'right' | null;
|
||||||
|
|
||||||
|
type ItemRect = { id: string; rect: DOMRect };
|
||||||
|
|
||||||
|
interface DropHint {
|
||||||
|
hoveredId: string | null;
|
||||||
|
dropSide: DropSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDropHint(
|
||||||
|
activeId: string | null,
|
||||||
|
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||||
|
cursorX: number,
|
||||||
|
cursorY: number,
|
||||||
|
): DropHint {
|
||||||
|
if (!activeId) {
|
||||||
|
return { hoveredId: null, dropSide: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ItemRect[] = Array.from(itemRefs.current.entries())
|
||||||
|
.filter((entry): entry is [string, HTMLDivElement] => !!entry[1] && entry[0] !== activeId)
|
||||||
|
.map(([itemId, element]) => ({
|
||||||
|
id: itemId,
|
||||||
|
rect: element.getBoundingClientRect(),
|
||||||
|
}))
|
||||||
|
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return { hoveredId: null, dropSide: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => a.rect.top - b.rect.top);
|
||||||
|
|
||||||
|
const rows: ItemRect[][] = [];
|
||||||
|
const rowTolerance = items[0].rect.height / 2;
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const currentRow = rows[rows.length - 1];
|
||||||
|
if (!currentRow) {
|
||||||
|
rows.push([item]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
|
||||||
|
if (isSameRow) {
|
||||||
|
currentRow.push(item);
|
||||||
|
} else {
|
||||||
|
rows.push([item]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let targetRow: ItemRect[] | undefined;
|
||||||
|
let smallestRowDistance = Infinity;
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const top = row[0].rect.top;
|
||||||
|
const bottom = row[0].rect.bottom;
|
||||||
|
const centerY = top + (bottom - top) / 2;
|
||||||
|
const distance = Math.abs(cursorY - centerY);
|
||||||
|
if (distance < smallestRowDistance) {
|
||||||
|
smallestRowDistance = distance;
|
||||||
|
targetRow = row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetRow || targetRow.length === 0) {
|
||||||
|
return { hoveredId: null, dropSide: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoveredItem = targetRow[0];
|
||||||
|
let smallestHorizontalDistance = Infinity;
|
||||||
|
|
||||||
|
targetRow.forEach((item) => {
|
||||||
|
const midpoint = item.rect.left + item.rect.width / 2;
|
||||||
|
const distance = Math.abs(cursorX - midpoint);
|
||||||
|
if (distance < smallestHorizontalDistance) {
|
||||||
|
smallestHorizontalDistance = distance;
|
||||||
|
hoveredItem = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstItem = targetRow[0];
|
||||||
|
const lastItem = targetRow[targetRow.length - 1];
|
||||||
|
|
||||||
|
let dropSide: DropSide;
|
||||||
|
if (cursorX < firstItem.rect.left) {
|
||||||
|
hoveredItem = firstItem;
|
||||||
|
dropSide = 'left';
|
||||||
|
} else if (cursorX > lastItem.rect.right) {
|
||||||
|
hoveredItem = lastItem;
|
||||||
|
dropSide = 'right';
|
||||||
|
} else {
|
||||||
|
const midpoint = hoveredItem.rect.left + hoveredItem.rect.width / 2;
|
||||||
|
dropSide = cursorX >= midpoint ? 'right' : 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hoveredId: hoveredItem.id, dropSide };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargetIndex<T extends DragDropItem>(
|
||||||
|
hoveredId: string | null,
|
||||||
|
dropSide: DropSide,
|
||||||
|
filteredItems: T[],
|
||||||
|
filteredToOriginalIndex: number[],
|
||||||
|
originalItemsLength: number,
|
||||||
|
fallbackIndex: number | null,
|
||||||
|
): number | null {
|
||||||
|
const convertFilteredIndexToOriginal = (filteredIndex: number): number => {
|
||||||
|
if (filteredToOriginalIndex.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredIndex <= 0) {
|
||||||
|
return filteredToOriginalIndex[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredIndex >= filteredToOriginalIndex.length) {
|
||||||
|
return originalItemsLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredToOriginalIndex[filteredIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hoveredId) {
|
||||||
|
const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId);
|
||||||
|
if (filteredIndex !== -1) {
|
||||||
|
const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0);
|
||||||
|
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackIndex !== null && fallbackIndex !== undefined) {
|
||||||
|
const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0);
|
||||||
|
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight wrapper that handles dnd-kit hooks for each visible item
|
||||||
|
interface DraggableItemProps<T extends DragDropItem> {
|
||||||
|
item: T;
|
||||||
|
index: number;
|
||||||
|
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
|
boxSelectedPageIds: string[];
|
||||||
|
clearBoxSelection: () => void;
|
||||||
|
getBoxSelection: () => string[];
|
||||||
|
activeId: string | null;
|
||||||
|
activeDragIds: string[];
|
||||||
|
justMoved: boolean;
|
||||||
|
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||||
|
onUpdateDropTarget: (itemId: string | null) => void;
|
||||||
|
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
||||||
|
zoomLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
|
||||||
|
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||||
|
const pageNumber = (item as any).pageNumber ?? index + 1;
|
||||||
|
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
|
||||||
|
id: item.id,
|
||||||
|
disabled: isPlaceholder,
|
||||||
|
data: {
|
||||||
|
index,
|
||||||
|
pageNumber,
|
||||||
|
getThumbnail: () => {
|
||||||
|
if (getThumbnailData) {
|
||||||
|
const data = getThumbnailData(item.id);
|
||||||
|
if (data?.src) return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = itemRefs.current.get(item.id);
|
||||||
|
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
|
||||||
|
if (imgElement?.src) {
|
||||||
|
return {
|
||||||
|
src: imgElement.src,
|
||||||
|
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||||
|
id: item.id,
|
||||||
|
data: { index, pageNumber: index + 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify parent when hover state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOver) {
|
||||||
|
onUpdateDropTarget(item.id);
|
||||||
|
} else {
|
||||||
|
onUpdateDropTarget(null);
|
||||||
|
}
|
||||||
|
}, [isOver, item.id, onUpdateDropTarget]);
|
||||||
|
|
||||||
|
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
setDraggableRef(element);
|
||||||
|
setDroppableRef(element);
|
||||||
|
}, [setDraggableRef, setDroppableRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const DragDropGrid = <T extends DragDropItem>({
|
const DragDropGrid = <T extends DragDropItem>({
|
||||||
items,
|
items,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
onReorderPages,
|
||||||
|
getThumbnailData,
|
||||||
|
zoomLevel = 1.0,
|
||||||
|
selectedFileIds,
|
||||||
}: DragDropGridProps<T>) => {
|
}: DragDropGridProps<T>) => {
|
||||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const getScrollElement = useCallback(() => {
|
||||||
|
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
|
||||||
|
const filtered: T[] = [];
|
||||||
|
const indexMap: number[] = [];
|
||||||
|
const selectedIds =
|
||||||
|
selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||||
|
if (isPlaceholder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const belongsToVisibleFile =
|
||||||
|
!selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
|
||||||
|
|
||||||
|
if (!belongsToVisibleFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.push(item);
|
||||||
|
indexMap.push(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
|
||||||
|
}, [items, selectedFileIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const visibleIdSet = new Set(visibleItems.map(item => item.id));
|
||||||
|
itemRefs.current.forEach((_, pageId) => {
|
||||||
|
if (!visibleIdSet.has(pageId)) {
|
||||||
|
itemRefs.current.delete(pageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [visibleItems]);
|
||||||
|
|
||||||
|
// Box selection state
|
||||||
|
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
|
||||||
|
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
|
||||||
|
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
|
||||||
|
const highlightTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
|
||||||
|
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
|
||||||
|
const [dropSide, setDropSide] = useState<DropSide>(null);
|
||||||
|
|
||||||
|
// Configure sensors for dnd-kit with activation constraint
|
||||||
|
// Require 10px movement before drag starts to allow clicks for selection
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Throttled pointer move handler for drop indicator
|
||||||
|
// Calculate drop position based on cursor location relative to ALL items, not just hovered item
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId) {
|
||||||
|
setDropSide(null);
|
||||||
|
setHoveredItemId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
|
// Use the actual cursor position (pointer coordinates)
|
||||||
|
const cursorX = e.clientX;
|
||||||
|
const cursorY = e.clientY;
|
||||||
|
|
||||||
|
if (rafId === null) {
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const hint = resolveDropHint(activeId, itemRefs, cursorX, cursorY);
|
||||||
|
setHoveredItemId(hint.hoveredId);
|
||||||
|
setDropSide(hint.dropSide);
|
||||||
|
rafId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handlePointerMove, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeId]);
|
||||||
|
|
||||||
// Responsive grid configuration
|
// Responsive grid configuration
|
||||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||||
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||||
|
|
||||||
// Calculate items per row based on container width
|
// Calculate items per row based on container width
|
||||||
const calculateItemsPerRow = useCallback(() => {
|
const calculateItemsPerRow = useCallback(() => {
|
||||||
@ -38,8 +374,8 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
|
|
||||||
// Convert rem to pixels for calculation
|
// Convert rem to pixels for calculation
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||||
|
|
||||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||||
@ -47,9 +383,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
const calculated = Math.floor(availableWidth / itemWithGap);
|
const calculated = Math.floor(availableWidth / itemWithGap);
|
||||||
|
|
||||||
return Math.max(1, calculated); // At least 1 item per row
|
return Math.max(1, calculated); // At least 1 item per row
|
||||||
}, []);
|
}, [zoomLevel]);
|
||||||
|
|
||||||
// Update items per row when container resizes
|
// Update items per row when container resizes or zoom changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateLayout = () => {
|
const updateLayout = () => {
|
||||||
const newItemsPerRow = calculateItemsPerRow();
|
const newItemsPerRow = calculateItemsPerRow();
|
||||||
@ -72,86 +408,410 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
window.removeEventListener('resize', updateLayout);
|
window.removeEventListener('resize', updateLayout);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [calculateItemsPerRow]);
|
}, [calculateItemsPerRow, zoomLevel]);
|
||||||
|
|
||||||
// Virtualization with react-virtual library
|
// Virtualization with react-virtual library
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: Math.ceil(items.length / itemsPerRow),
|
count: Math.ceil(visibleItems.length / itemsPerRow),
|
||||||
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
getScrollElement,
|
||||||
estimateSize: () => {
|
estimateSize: () => {
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
|
||||||
},
|
},
|
||||||
overscan: OVERSCAN,
|
overscan: OVERSCAN,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-measure virtualizer when zoom or items per row changes
|
||||||
|
useEffect(() => {
|
||||||
|
rowVirtualizer.measure();
|
||||||
|
}, [zoomLevel, itemsPerRow, visibleItems.length]);
|
||||||
|
|
||||||
|
// Cleanup highlight timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (highlightTimeoutRef.current) {
|
||||||
|
window.clearTimeout(highlightTimeoutRef.current);
|
||||||
|
highlightTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Box selection handlers
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return; // Only respond to primary button
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const clickTarget = e.target as Node;
|
||||||
|
let clickedPageId: string | null = null;
|
||||||
|
|
||||||
|
itemRefs.current.forEach((element, pageId) => {
|
||||||
|
if (element.contains(clickTarget)) {
|
||||||
|
clickedPageId = pageId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clickedPageId) {
|
||||||
|
// Clicking directly on a page shouldn't initiate box selection
|
||||||
|
// but clear previous box selection if clicking outside current group
|
||||||
|
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
|
||||||
|
setBoxSelectedPageIds([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
setIsBoxSelecting(true);
|
||||||
|
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
setBoxSelectedPageIds([]);
|
||||||
|
}, [boxSelectedPageIds]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!isBoxSelecting || !boxSelectStart) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
|
||||||
|
// Calculate which pages intersect with selection box
|
||||||
|
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
|
||||||
|
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
|
||||||
|
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
|
||||||
|
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
|
||||||
|
|
||||||
|
const selectedIds: string[] = [];
|
||||||
|
itemRefs.current.forEach((pageEl, pageId) => {
|
||||||
|
const pageRect = pageEl.getBoundingClientRect();
|
||||||
|
const pageLeft = pageRect.left - rect.left;
|
||||||
|
const pageRight = pageRect.right - rect.left;
|
||||||
|
const pageTop = pageRect.top - rect.top;
|
||||||
|
const pageBottom = pageRect.bottom - rect.top;
|
||||||
|
|
||||||
|
// Check if page intersects with selection box
|
||||||
|
const intersects = !(
|
||||||
|
pageRight < boxLeft ||
|
||||||
|
pageLeft > boxRight ||
|
||||||
|
pageBottom < boxTop ||
|
||||||
|
pageTop > boxBottom
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intersects) {
|
||||||
|
selectedIds.push(pageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setBoxSelectedPageIds(selectedIds);
|
||||||
|
}, [isBoxSelecting, boxSelectStart]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (isBoxSelecting) {
|
||||||
|
// Keep box-selected pages highlighted (don't clear boxSelectedPageIds yet)
|
||||||
|
// They will remain highlighted until next interaction
|
||||||
|
setIsBoxSelecting(false);
|
||||||
|
setBoxSelectStart(null);
|
||||||
|
setBoxSelectEnd(null);
|
||||||
|
}
|
||||||
|
}, [isBoxSelecting]);
|
||||||
|
|
||||||
|
// Function to clear box selection (exposed to child components)
|
||||||
|
const clearBoxSelection = useCallback(() => {
|
||||||
|
setBoxSelectedPageIds([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to get current box selection (exposed to child components)
|
||||||
|
const getBoxSelection = useCallback(() => {
|
||||||
|
return boxSelectedPageIds;
|
||||||
|
}, [boxSelectedPageIds]);
|
||||||
|
|
||||||
|
// Handle drag start
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
const activeId = event.active.id as string;
|
||||||
|
setActiveId(activeId);
|
||||||
|
|
||||||
|
// Call the getter function to get fresh thumbnail data
|
||||||
|
const getThumbnail = event.active.data.current?.getThumbnail;
|
||||||
|
if (getThumbnail) {
|
||||||
|
const thumbnailData = getThumbnail();
|
||||||
|
if (thumbnailData?.src) {
|
||||||
|
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragPreview(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
// Handle drag cancel
|
||||||
|
const handleDragCancel = useCallback(() => {
|
||||||
|
setActiveId(null);
|
||||||
|
setDragPreview(null);
|
||||||
|
setHoveredItemId(null);
|
||||||
|
setDropSide(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle drag end
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
const finalDropSide = dropSide;
|
||||||
|
setActiveId(null);
|
||||||
|
setDragPreview(null);
|
||||||
|
setHoveredItemId(null);
|
||||||
|
setDropSide(null);
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeData = active.data.current;
|
||||||
|
if (!activeData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePageNumber = activeData.pageNumber;
|
||||||
|
|
||||||
|
const overData = over?.data.current;
|
||||||
|
let targetIndex = resolveTargetIndex(
|
||||||
|
hoveredItemId,
|
||||||
|
finalDropSide,
|
||||||
|
visibleItems,
|
||||||
|
filteredToOriginalIndex,
|
||||||
|
items.length,
|
||||||
|
overData ? overData.index : null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetIndex < 0) targetIndex = 0;
|
||||||
|
if (targetIndex > items.length) targetIndex = items.length;
|
||||||
|
|
||||||
|
// Check if this page is box-selected
|
||||||
|
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
||||||
|
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
||||||
|
|
||||||
|
// Call reorder with page number and target index
|
||||||
|
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
||||||
|
|
||||||
|
// Highlight moved pages briefly
|
||||||
|
const movedIds = pagesToDrag ?? [active.id as string];
|
||||||
|
setJustMovedIds(movedIds);
|
||||||
|
if (highlightTimeoutRef.current) {
|
||||||
|
window.clearTimeout(highlightTimeoutRef.current);
|
||||||
|
}
|
||||||
|
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setJustMovedIds([]);
|
||||||
|
highlightTimeoutRef.current = null;
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
// Clear box selection after drag
|
||||||
|
if (pagesToDrag) {
|
||||||
|
clearBoxSelection();
|
||||||
|
}
|
||||||
|
}, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]);
|
||||||
|
|
||||||
// Calculate optimal width for centering
|
// Calculate optimal width for centering
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||||
|
|
||||||
return (
|
// Calculate selection box dimensions
|
||||||
<Box
|
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
|
||||||
ref={containerRef}
|
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
|
||||||
style={{
|
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
|
||||||
// Basic container styles
|
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
|
||||||
width: '100%',
|
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
|
||||||
height: '100%',
|
zIndex: Z_INDEX_SELECTION_BOX,
|
||||||
}}
|
} : null;
|
||||||
>
|
|
||||||
<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 (
|
// Calculate drop indicator position
|
||||||
<div
|
const dropIndicatorStyle = useMemo(() => {
|
||||||
key={virtualRow.index}
|
if (!hoveredItemId || !dropSide || !activeId) return null;
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
const element = itemRefs.current.get(hoveredItemId);
|
||||||
top: 0,
|
const container = containerRef.current;
|
||||||
left: 0,
|
if (!element || !container) return null;
|
||||||
width: '100%',
|
|
||||||
height: `${virtualRow.size}px`,
|
const itemRect = element.getBoundingClientRect();
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
const containerRect = container.getBoundingClientRect();
|
||||||
}}
|
|
||||||
>
|
const top = itemRect.top - containerRect.top;
|
||||||
|
const height = itemRect.height;
|
||||||
|
const left = dropSide === 'left'
|
||||||
|
? itemRect.left - containerRect.left - itemGap / 2
|
||||||
|
: itemRect.right - containerRect.left + itemGap / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
zIndex: Z_INDEX_DROP_INDICATOR,
|
||||||
|
};
|
||||||
|
}, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]);
|
||||||
|
|
||||||
|
const activeDragIds = useMemo(() => {
|
||||||
|
if (!activeId) return [];
|
||||||
|
if (boxSelectedPageIds.includes(activeId)) {
|
||||||
|
return boxSelectedPageIds;
|
||||||
|
}
|
||||||
|
return [activeId];
|
||||||
|
}, [activeId, boxSelectedPageIds]);
|
||||||
|
|
||||||
|
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
if (!activeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollElement = getScrollElement();
|
||||||
|
if (!scrollElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollElement.scrollBy({
|
||||||
|
top: event.deltaY,
|
||||||
|
left: event.deltaX,
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}, [activeId, getScrollElement]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
ref={containerRef}
|
||||||
|
className={styles.gridContainer}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onWheel={handleWheelWhileDragging}
|
||||||
|
>
|
||||||
|
{selectionBoxStyle && (
|
||||||
|
<div
|
||||||
|
className={styles.selectionBox}
|
||||||
|
style={selectionBoxStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dropIndicatorStyle && (
|
||||||
|
<div
|
||||||
|
className={styles.dropIndicator}
|
||||||
|
style={dropIndicatorStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.virtualRows}
|
||||||
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
maxWidth: `${gridWidth}px`,
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const startIndex = virtualRow.index * itemsPerRow;
|
||||||
|
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
|
||||||
|
const rowItems = visibleItems.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={virtualRow.index}
|
||||||
|
className={styles.virtualRow}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
height: `${virtualRow.size}px`,
|
||||||
gap: GRID_CONSTANTS.ITEM_GAP,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
justifyContent: 'flex-start',
|
|
||||||
height: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'relative'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rowItems.map((item, itemIndex) => {
|
<div
|
||||||
const actualIndex = startIndex + itemIndex;
|
className={styles.rowContent}
|
||||||
return (
|
style={{
|
||||||
<React.Fragment key={item.id}>
|
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
|
||||||
{/* Item */}
|
}}
|
||||||
{renderItem(item, actualIndex, itemRefs)}
|
>
|
||||||
</React.Fragment>
|
{rowItems.map((item, itemIndex) => {
|
||||||
);
|
const actualIndex = startIndex + itemIndex;
|
||||||
})}
|
return (
|
||||||
|
<DraggableItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
index={actualIndex}
|
||||||
|
itemRefs={itemRefs}
|
||||||
|
boxSelectedPageIds={boxSelectedPageIds}
|
||||||
|
clearBoxSelection={clearBoxSelection}
|
||||||
|
getBoxSelection={getBoxSelection}
|
||||||
|
activeId={activeId}
|
||||||
|
activeDragIds={activeDragIds}
|
||||||
|
justMoved={justMovedIds.includes(item.id)}
|
||||||
|
getThumbnailData={getThumbnailData}
|
||||||
|
onUpdateDropTarget={setHoveredItemId}
|
||||||
|
renderItem={renderItem}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
{/* Drag Overlay */}
|
||||||
|
<DragOverlay>
|
||||||
|
{activeId && (
|
||||||
|
<div className={styles.dragOverlay}>
|
||||||
|
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
||||||
|
<div
|
||||||
|
className={styles.dragOverlayBadge}
|
||||||
|
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
|
||||||
|
>
|
||||||
|
{boxSelectedPageIds.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dragPreview ? (
|
||||||
|
<img
|
||||||
|
src={dragPreview.src}
|
||||||
|
alt="Dragging"
|
||||||
|
style={{
|
||||||
|
width: `calc(20rem * ${zoomLevel})`,
|
||||||
|
height: `calc(20rem * ${zoomLevel})`,
|
||||||
|
objectFit: 'contain',
|
||||||
|
transform: `rotate(${dragPreview.rotation}deg)`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={styles.dragOverlayPreview}
|
||||||
|
style={{
|
||||||
|
width: `calc(20rem * ${zoomLevel})`,
|
||||||
|
height: `calc(20rem * ${zoomLevel})`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '2rem',
|
||||||
|
color: 'var(--mantine-color-dimmed)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="description" width="3rem" height="3rem" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,26 @@
|
|||||||
transform: scale(1.02) translateZ(0);
|
transform: scale(1.02) translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pageSurface {
|
||||||
|
transition: background-color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageJustMoved {
|
||||||
|
animation: pageMovedHighlight 1.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pageMovedHighlight {
|
||||||
|
0% {
|
||||||
|
background-color: rgba(59, 130, 246, 0.32);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
background-color: rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: rgba(59, 130, 246, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pageContainer:hover .pageNumber {
|
.pageContainer:hover .pageNumber {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
@ -30,28 +50,6 @@
|
|||||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
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 */
|
/* Animations */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -138,12 +138,12 @@ const PageEditorControls = ({
|
|||||||
|
|
||||||
{/* Undo/Redo */}
|
{/* Undo/Redo */}
|
||||||
<Tooltip label="Undo">
|
<Tooltip label="Undo">
|
||||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
|
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" style={{ color: canUndo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
||||||
<UndoIcon />
|
<UndoIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Redo">
|
<Tooltip label="Redo">
|
||||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
|
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" style={{ color: canRedo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
||||||
<RedoIcon />
|
<RedoIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -156,7 +156,7 @@ const PageEditorControls = ({
|
|||||||
onClick={() => onRotate('left')}
|
onClick={() => onRotate('left')}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@ -168,7 +168,7 @@ const PageEditorControls = ({
|
|||||||
onClick={() => onRotate('right')}
|
onClick={() => onRotate('right')}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@ -180,7 +180,7 @@ const PageEditorControls = ({
|
|||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@ -192,7 +192,7 @@ const PageEditorControls = ({
|
|||||||
onClick={onSplit}
|
onClick={onSplit}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@ -204,7 +204,7 @@ const PageEditorControls = ({
|
|||||||
onClick={onPageBreak}
|
onClick={onPageBreak}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -8,12 +8,13 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
|
|||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
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 { PDFPage, PDFDocument } from '@app/types/pageEditor';
|
||||||
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
|
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||||
|
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
||||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
||||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||||
|
import { StirlingFileStub } from '@app/types/fileContext';
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
|
|
||||||
|
|
||||||
@ -22,11 +23,17 @@ interface PageThumbnailProps {
|
|||||||
index: number;
|
index: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
originalFile?: File;
|
originalFile?: File;
|
||||||
|
fileColorIndex: number;
|
||||||
selectedPageIds: string[];
|
selectedPageIds: string[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
movingPage: number | null;
|
movingPage: number | null;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
|
isBoxSelected?: boolean;
|
||||||
|
clearBoxSelection?: () => void;
|
||||||
|
activeDragIds: string[];
|
||||||
|
justMoved?: boolean;
|
||||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
|
dragHandleProps?: any;
|
||||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||||
onTogglePage: (pageId: string) => void;
|
onTogglePage: (pageId: string) => void;
|
||||||
onAnimateReorder: () => void;
|
onAnimateReorder: () => void;
|
||||||
@ -40,19 +47,25 @@ interface PageThumbnailProps {
|
|||||||
pdfDocument: PDFDocument;
|
pdfDocument: PDFDocument;
|
||||||
setPdfDocument: (doc: PDFDocument) => void;
|
setPdfDocument: (doc: PDFDocument) => void;
|
||||||
splitPositions: Set<number>;
|
splitPositions: Set<number>;
|
||||||
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
|
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
|
||||||
|
zoomLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||||
page,
|
page,
|
||||||
index,
|
index: _index,
|
||||||
totalPages,
|
totalPages,
|
||||||
originalFile,
|
originalFile,
|
||||||
|
fileColorIndex,
|
||||||
selectedPageIds,
|
selectedPageIds,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
movingPage,
|
movingPage,
|
||||||
isAnimating,
|
isAnimating,
|
||||||
|
isBoxSelected = false,
|
||||||
|
clearBoxSelection,
|
||||||
|
activeDragIds,
|
||||||
pageRefs,
|
pageRefs,
|
||||||
|
dragHandleProps,
|
||||||
onReorderPages,
|
onReorderPages,
|
||||||
onTogglePage,
|
onTogglePage,
|
||||||
onExecuteCommand,
|
onExecuteCommand,
|
||||||
@ -64,17 +77,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
pdfDocument,
|
pdfDocument,
|
||||||
splitPositions,
|
splitPositions,
|
||||||
onInsertFiles,
|
onInsertFiles,
|
||||||
|
zoomLevel = 1.0,
|
||||||
|
justMoved = false,
|
||||||
}: PageThumbnailProps) => {
|
}: PageThumbnailProps) => {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const pageIndex = page.pageNumber - 1;
|
||||||
|
|
||||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
const lastClickTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration();
|
||||||
const { openFilesModal } = useFilesModalContext();
|
const { openFilesModal } = useFilesModalContext();
|
||||||
|
|
||||||
|
// Check if this page is currently being dragged
|
||||||
|
const isDragging = activeDragIds.includes(page.id);
|
||||||
|
|
||||||
// Calculate document aspect ratio from first non-blank page
|
// Calculate document aspect ratio from first non-blank page
|
||||||
const getDocumentAspectRatio = useCallback(() => {
|
const getDocumentAspectRatio = useCallback(() => {
|
||||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||||
@ -131,63 +152,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
};
|
};
|
||||||
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
|
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
|
||||||
|
|
||||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
// Merge refs - combine our ref tracking with dnd-kit's ref
|
||||||
|
const mergedRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
// Track in our refs map
|
||||||
|
elementRef.current = element;
|
||||||
if (element) {
|
if (element) {
|
||||||
pageRefs.current.set(page.id, 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 {
|
} else {
|
||||||
pageRefs.current.delete(page.id);
|
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]);
|
|
||||||
|
// Call dnd-kit's ref if provided
|
||||||
|
if (dragHandleProps?.ref) {
|
||||||
|
dragHandleProps.ref(element);
|
||||||
|
}
|
||||||
|
}, [page.id, pageRefs, dragHandleProps]);
|
||||||
|
|
||||||
|
|
||||||
// DOM command handlers
|
// DOM command handlers
|
||||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
||||||
@ -216,13 +196,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Create a command to toggle split at this position
|
// Create a command to toggle split at this position
|
||||||
const command = createSplitCommand(index);
|
const command = createSplitCommand(pageIndex);
|
||||||
onExecuteCommand(command);
|
onExecuteCommand(command);
|
||||||
|
|
||||||
const hasSplit = splitPositions.has(index);
|
const hasSplit = splitPositions.has(pageIndex);
|
||||||
const action = hasSplit ? 'removed' : 'added';
|
const action = hasSplit ? 'removed' : 'added';
|
||||||
onSetStatus(`Split marker ${action} after position ${index + 1}`);
|
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
||||||
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
}, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
||||||
|
|
||||||
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
|
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -231,9 +211,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
// Open file manager modal with custom handler for page insertion
|
// Open file manager modal with custom handler for page insertion
|
||||||
openFilesModal({
|
openFilesModal({
|
||||||
insertAfterPage: page.pageNumber,
|
insertAfterPage: page.pageNumber,
|
||||||
customHandler: (files: File[], insertAfterPage?: number) => {
|
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
||||||
if (insertAfterPage !== undefined) {
|
if (insertAfterPage !== undefined) {
|
||||||
onInsertFiles(files, insertAfterPage);
|
onInsertFiles(files, insertAfterPage, isFromStorage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -263,14 +243,28 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
||||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
// If mouse moved less than 5 pixels, consider it a click (not a drag)
|
// If mouse moved less than 2 pixels, consider it a click (not a drag)
|
||||||
if (distance < 5 && !isDragging) {
|
if (distance < 2 && !isDragging) {
|
||||||
onTogglePage(page.id);
|
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastClickTimeRef.current > 100) {
|
||||||
|
lastClickTimeRef.current = now;
|
||||||
|
|
||||||
|
// Clear box selection when clicking on a non-selected page
|
||||||
|
if (!isBoxSelected && clearBoxSelection) {
|
||||||
|
clearBoxSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't toggle page selection if it's box-selected (just keep the box selection)
|
||||||
|
if (!isBoxSelected) {
|
||||||
|
onTogglePage(page.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsMouseDown(false);
|
setIsMouseDown(false);
|
||||||
setMouseStartPos(null);
|
setMouseStartPos(null);
|
||||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
|
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
setIsMouseDown(false);
|
setIsMouseDown(false);
|
||||||
@ -278,6 +272,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||||
|
|
||||||
|
// Spread dragHandleProps but use our merged ref
|
||||||
|
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
||||||
|
|
||||||
// Build hover menu actions
|
// Build hover menu actions
|
||||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||||
{
|
{
|
||||||
@ -286,14 +285,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
label: 'Move Left',
|
label: 'Move Left',
|
||||||
onClick: (e) => {
|
onClick: (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (index > 0 && !movingPage && !isAnimating) {
|
if (pageIndex > 0 && !movingPage && !isAnimating) {
|
||||||
onSetMovingPage(page.pageNumber);
|
onSetMovingPage(page.pageNumber);
|
||||||
onReorderPages(page.pageNumber, index - 1);
|
onReorderPages(page.pageNumber, pageIndex - 1);
|
||||||
setTimeout(() => onSetMovingPage(null), 650);
|
setTimeout(() => onSetMovingPage(null), 650);
|
||||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
disabled: index === 0
|
disabled: pageIndex === 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'move-right',
|
id: 'move-right',
|
||||||
@ -301,14 +300,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
label: 'Move Right',
|
label: 'Move Right',
|
||||||
onClick: (e) => {
|
onClick: (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
|
||||||
onSetMovingPage(page.pageNumber);
|
onSetMovingPage(page.pageNumber);
|
||||||
onReorderPages(page.pageNumber, index + 1);
|
// ReorderPagesCommand expects target index relative to the original array.
|
||||||
|
// When moving toward the right (higher index), provide desiredIndex + 1
|
||||||
|
// so the command's internal adjustment (targetIndex - 1) lands correctly.
|
||||||
|
onReorderPages(page.pageNumber, pageIndex + 2);
|
||||||
setTimeout(() => onSetMovingPage(null), 650);
|
setTimeout(() => onSetMovingPage(null), 650);
|
||||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
disabled: index === totalPages - 1
|
disabled: pageIndex === totalPages - 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rotate-left',
|
id: 'rotate-left',
|
||||||
@ -334,7 +336,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
||||||
label: 'Split After',
|
label: 'Split After',
|
||||||
onClick: handleSplit,
|
onClick: handleSplit,
|
||||||
hidden: index >= totalPages - 1,
|
hidden: pageIndex >= totalPages - 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'insert',
|
id: 'insert',
|
||||||
@ -342,11 +344,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
label: 'Insert File After',
|
label: 'Insert File After',
|
||||||
onClick: handleInsertFileAfter,
|
onClick: handleInsertFileAfter,
|
||||||
}
|
}
|
||||||
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
], [pageIndex, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={pageElementRef}
|
ref={mergedRef}
|
||||||
|
{...restDragProps}
|
||||||
data-page-id={page.id}
|
data-page-id={page.id}
|
||||||
data-page-number={page.pageNumber}
|
data-page-number={page.pageNumber}
|
||||||
className={`
|
className={`
|
||||||
@ -354,24 +357,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
!rounded-lg
|
!rounded-lg
|
||||||
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
||||||
select-none
|
select-none
|
||||||
w-[20rem]
|
|
||||||
h-[20rem]
|
|
||||||
flex items-center justify-center
|
flex items-center justify-center
|
||||||
flex-shrink-0
|
flex-shrink-0
|
||||||
shadow-sm
|
shadow-sm
|
||||||
hover:shadow-md
|
hover:shadow-md
|
||||||
transition-all
|
transition-all
|
||||||
relative
|
relative
|
||||||
${selectionMode
|
|
||||||
? 'bg-white hover:bg-gray-50'
|
|
||||||
: 'bg-white hover:bg-gray-50'}
|
|
||||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||||
|
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
width: `calc(20rem * ${zoomLevel})`,
|
||||||
|
height: `calc(20rem * ${zoomLevel})`,
|
||||||
|
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
||||||
|
zIndex: isHovered ? 50 : 1,
|
||||||
|
...(isBoxSelected && {
|
||||||
|
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
draggable={false}
|
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
@ -414,12 +418,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
|
|
||||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||||
<div
|
<div
|
||||||
|
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
border: '1px solid var(--mantine-color-gray-3)',
|
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -440,11 +445,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
border: '1px solid #e9ecef',
|
border: '1px solid #e9ecef',
|
||||||
borderRadius: 2
|
borderRadius: 2
|
||||||
}}></div>
|
}} />
|
||||||
</div>
|
</div>
|
||||||
) : thumbnailUrl ? (
|
) : thumbnailUrl ? (
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<img
|
<img
|
||||||
|
className="ph-no-capture"
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={`Page ${page.pageNumber}`}
|
alt={`Page ${page.pageNumber}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@ -476,7 +482,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 5,
|
top: 5,
|
||||||
left: 5,
|
left: 5,
|
||||||
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
|
background: 'rgba(162, 201, 255, 0.8)',
|
||||||
padding: '6px 8px',
|
padding: '6px 8px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { FileId } from '@app/types/file';
|
import { FileId } from '@app/types/file';
|
||||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
import { PDFDocument, PDFPage, PageBreakSettings } from '@app/types/pageEditor';
|
||||||
|
|
||||||
// V1-style DOM-first command system (replaces the old React state commands)
|
// V1-style DOM-first command system (replaces the old React state commands)
|
||||||
export abstract class DOMCommand {
|
export abstract class DOMCommand {
|
||||||
@ -67,7 +67,7 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
private pagesToDelete: number[],
|
private pagesToDelete: number[],
|
||||||
private getCurrentDocument: () => PDFDocument | null,
|
private getCurrentDocument: () => PDFDocument | null,
|
||||||
private setDocument: (doc: PDFDocument) => void,
|
private setDocument: (doc: PDFDocument) => void,
|
||||||
private setSelectedPages: (pages: number[]) => void,
|
private setSelectedPageIds: (pageIds: string[]) => void,
|
||||||
private getSplitPositions: () => Set<number>,
|
private getSplitPositions: () => Set<number>,
|
||||||
private setSplitPositions: (positions: Set<number>) => void,
|
private setSplitPositions: (positions: Set<number>) => void,
|
||||||
private getSelectedPages: () => number[],
|
private getSelectedPages: () => number[],
|
||||||
@ -99,6 +99,13 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
this.hasExecuted = true;
|
this.hasExecuted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedPageNumbersBefore = this.getSelectedPages();
|
||||||
|
const selectedIdSet = new Set(
|
||||||
|
selectedPageNumbersBefore
|
||||||
|
.map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
);
|
||||||
|
|
||||||
// Filter out deleted pages by ID (stable across undo/redo)
|
// Filter out deleted pages by ID (stable across undo/redo)
|
||||||
const remainingPages = currentDoc.pages.filter(page =>
|
const remainingPages = currentDoc.pages.filter(page =>
|
||||||
!this.pageIdsToDelete.includes(page.id)
|
!this.pageIdsToDelete.includes(page.id)
|
||||||
@ -106,7 +113,7 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
|
|
||||||
if (remainingPages.length === 0) {
|
if (remainingPages.length === 0) {
|
||||||
// If all pages would be deleted, clear selection/splits and close PDF
|
// If all pages would be deleted, clear selection/splits and close PDF
|
||||||
this.setSelectedPages([]);
|
this.setSelectedPageIds([]);
|
||||||
this.setSplitPositions(new Set());
|
this.setSplitPositions(new Set());
|
||||||
this.onAllPagesDeleted?.();
|
this.onAllPagesDeleted?.();
|
||||||
return;
|
return;
|
||||||
@ -135,7 +142,12 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
|
|
||||||
// Apply changes
|
// Apply changes
|
||||||
this.setDocument(updatedDocument);
|
this.setDocument(updatedDocument);
|
||||||
this.setSelectedPages([]);
|
|
||||||
|
const remainingSelectedPageIds = remainingPages
|
||||||
|
.filter((page) => selectedIdSet.has(page.id))
|
||||||
|
.map((page) => page.id);
|
||||||
|
this.setSelectedPageIds(remainingSelectedPageIds);
|
||||||
|
|
||||||
this.setSplitPositions(newPositions);
|
this.setSplitPositions(newPositions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +157,12 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
// Simply restore the complete original document state
|
// Simply restore the complete original document state
|
||||||
this.setDocument(this.originalDocument);
|
this.setDocument(this.originalDocument);
|
||||||
this.setSplitPositions(this.originalSplitPositions);
|
this.setSplitPositions(this.originalSplitPositions);
|
||||||
this.setSelectedPages(this.originalSelectedPages);
|
const restoredIds = this.originalSelectedPages
|
||||||
|
.map((pageNum) =>
|
||||||
|
this.originalDocument!.pages.find((page) => page.pageNumber === pageNum)?.id || ""
|
||||||
|
)
|
||||||
|
.filter((id) => id !== "");
|
||||||
|
this.setSelectedPageIds(restoredIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
@ -161,7 +178,8 @@ export class ReorderPagesCommand extends DOMCommand {
|
|||||||
private targetIndex: number,
|
private targetIndex: number,
|
||||||
private selectedPages: number[] | undefined,
|
private selectedPages: number[] | undefined,
|
||||||
private getCurrentDocument: () => PDFDocument | null,
|
private getCurrentDocument: () => PDFDocument | null,
|
||||||
private setDocument: (doc: PDFDocument) => void
|
private setDocument: (doc: PDFDocument) => void,
|
||||||
|
private onReorderComplete?: (newPages: PDFPage[]) => void
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@ -196,7 +214,13 @@ export class ReorderPagesCommand extends DOMCommand {
|
|||||||
} else {
|
} else {
|
||||||
// Single page reorder
|
// Single page reorder
|
||||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||||
newPages.splice(this.targetIndex, 0, movedPage);
|
|
||||||
|
// Adjust target index if moving forward (after removal, indices shift)
|
||||||
|
const adjustedTargetIndex = sourceIndex < this.targetIndex
|
||||||
|
? this.targetIndex - 1
|
||||||
|
: this.targetIndex;
|
||||||
|
|
||||||
|
newPages.splice(adjustedTargetIndex, 0, movedPage);
|
||||||
|
|
||||||
newPages.forEach((page, index) => {
|
newPages.forEach((page, index) => {
|
||||||
page.pageNumber = index + 1;
|
page.pageNumber = index + 1;
|
||||||
@ -210,6 +234,11 @@ export class ReorderPagesCommand extends DOMCommand {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.setDocument(reorderedDocument);
|
this.setDocument(reorderedDocument);
|
||||||
|
|
||||||
|
// Notify that reordering is complete
|
||||||
|
if (this.onReorderComplete) {
|
||||||
|
this.onReorderComplete(newPages);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
@ -408,6 +437,8 @@ export class SplitAllCommand extends DOMCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PageBreakSettings, PageSize, and PageOrientation are now imported from pageEditor.ts
|
||||||
|
|
||||||
export class PageBreakCommand extends DOMCommand {
|
export class PageBreakCommand extends DOMCommand {
|
||||||
private insertedPages: PDFPage[] = [];
|
private insertedPages: PDFPage[] = [];
|
||||||
private originalDocument: PDFDocument | null = null;
|
private originalDocument: PDFDocument | null = null;
|
||||||
@ -415,7 +446,8 @@ export class PageBreakCommand extends DOMCommand {
|
|||||||
constructor(
|
constructor(
|
||||||
private selectedPageNumbers: number[],
|
private selectedPageNumbers: number[],
|
||||||
private getCurrentDocument: () => PDFDocument | null,
|
private getCurrentDocument: () => PDFDocument | null,
|
||||||
private setDocument: (doc: PDFDocument) => void
|
private setDocument: (doc: PDFDocument) => void,
|
||||||
|
private settings?: PageBreakSettings
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@ -450,7 +482,8 @@ export class PageBreakCommand extends DOMCommand {
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
selected: false,
|
selected: false,
|
||||||
splitAfter: false,
|
splitAfter: false,
|
||||||
isBlankPage: true // Custom flag for blank pages
|
isBlankPage: true, // Custom flag for blank pages
|
||||||
|
pageBreakSettings: this.settings // Store settings for export
|
||||||
};
|
};
|
||||||
newPages.push(blankPage);
|
newPages.push(blankPage);
|
||||||
this.insertedPages.push(blankPage);
|
this.insertedPages.push(blankPage);
|
||||||
@ -883,6 +916,10 @@ export class UndoManager {
|
|||||||
return this.redoStack.length > 0;
|
return this.redoStack.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasHistory(): boolean {
|
||||||
|
return this.undoStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.undoStack = [];
|
this.undoStack = [];
|
||||||
this.redoStack = [];
|
this.redoStack = [];
|
||||||
|
|||||||
61
frontend/src/core/components/pageEditor/fileColors.ts
Normal file
61
frontend/src/core/components/pageEditor/fileColors.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* File color palette for page editor
|
||||||
|
* Each file gets a distinct color for visual organization
|
||||||
|
* Colors are applied at 0.3 opacity for subtle highlighting
|
||||||
|
* Maximum 20 files supported in page editor
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FILE_COLORS = [
|
||||||
|
// Subtle colors (1-6) - fit well with UI theme
|
||||||
|
'rgb(59, 130, 246)', // Blue
|
||||||
|
'rgb(16, 185, 129)', // Green
|
||||||
|
'rgb(139, 92, 246)', // Purple
|
||||||
|
'rgb(6, 182, 212)', // Cyan
|
||||||
|
'rgb(20, 184, 166)', // Teal
|
||||||
|
'rgb(99, 102, 241)', // Indigo
|
||||||
|
|
||||||
|
// Mid-range colors (7-12) - more distinct
|
||||||
|
'rgb(244, 114, 182)', // Pink
|
||||||
|
'rgb(251, 146, 60)', // Orange
|
||||||
|
'rgb(234, 179, 8)', // Yellow
|
||||||
|
'rgb(132, 204, 22)', // Lime
|
||||||
|
'rgb(248, 113, 113)', // Red
|
||||||
|
'rgb(168, 85, 247)', // Violet
|
||||||
|
|
||||||
|
// Vibrant colors (13-20) - maximum distinction
|
||||||
|
'rgb(236, 72, 153)', // Fuchsia
|
||||||
|
'rgb(245, 158, 11)', // Amber
|
||||||
|
'rgb(34, 197, 94)', // Emerald
|
||||||
|
'rgb(14, 165, 233)', // Sky
|
||||||
|
'rgb(239, 68, 68)', // Rose
|
||||||
|
'rgb(168, 162, 158)', // Stone
|
||||||
|
'rgb(251, 191, 36)', // Gold
|
||||||
|
'rgb(192, 132, 252)', // Light Purple
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MAX_PAGE_EDITOR_FILES = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for a file by its index
|
||||||
|
* @param index - Zero-based file index
|
||||||
|
* @returns RGB color string
|
||||||
|
*/
|
||||||
|
export function getFileColor(index: number): string {
|
||||||
|
if (index < 0 || index >= FILE_COLORS.length) {
|
||||||
|
console.warn(`File index ${index} out of range, using default color`);
|
||||||
|
return FILE_COLORS[0];
|
||||||
|
}
|
||||||
|
return FILE_COLORS[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color with specified opacity
|
||||||
|
* @param index - Zero-based file index
|
||||||
|
* @param opacity - Opacity value (0-1), defaults to 0.3
|
||||||
|
* @returns RGBA color string
|
||||||
|
*/
|
||||||
|
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
|
||||||
|
const rgb = getFileColor(index);
|
||||||
|
// Convert rgb(r, g, b) to rgba(r, g, b, a)
|
||||||
|
return rgb.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`);
|
||||||
|
}
|
||||||
@ -0,0 +1,249 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { FileId } from "@app/types/file";
|
||||||
|
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||||
|
|
||||||
|
interface UseEditedDocumentStateParams {
|
||||||
|
initialDocument: PDFDocument | null;
|
||||||
|
mergedPdfDocument: PDFDocument | null;
|
||||||
|
reorderedPages: PDFPage[] | null;
|
||||||
|
clearReorderedPages: () => void;
|
||||||
|
fileOrder: FileId[];
|
||||||
|
updateCurrentPages: (pages: PDFPage[] | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditedDocumentState = ({
|
||||||
|
initialDocument,
|
||||||
|
mergedPdfDocument,
|
||||||
|
reorderedPages,
|
||||||
|
clearReorderedPages,
|
||||||
|
fileOrder,
|
||||||
|
updateCurrentPages,
|
||||||
|
}: UseEditedDocumentStateParams) => {
|
||||||
|
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
||||||
|
const editedDocumentRef = useRef<PDFDocument | null>(null);
|
||||||
|
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
|
||||||
|
const lastSyncedSignatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Clone the initial document once so we can safely mutate working state
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialDocument || editedDocument) return;
|
||||||
|
|
||||||
|
setEditedDocument({
|
||||||
|
...initialDocument,
|
||||||
|
pages: initialDocument.pages.map((page) => ({ ...page })),
|
||||||
|
});
|
||||||
|
}, [initialDocument, editedDocument]);
|
||||||
|
|
||||||
|
// Apply reorders triggered elsewhere in the editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reorderedPages || !editedDocument) return;
|
||||||
|
|
||||||
|
setEditedDocument({
|
||||||
|
...editedDocument,
|
||||||
|
pages: reorderedPages,
|
||||||
|
});
|
||||||
|
clearReorderedPages();
|
||||||
|
}, [reorderedPages, editedDocument, clearReorderedPages]);
|
||||||
|
|
||||||
|
// Keep ref synced so effects can read latest without re-running
|
||||||
|
useEffect(() => {
|
||||||
|
editedDocumentRef.current = editedDocument;
|
||||||
|
}, [editedDocument]);
|
||||||
|
|
||||||
|
// Cache page positions to help future insertions preserve intent
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editedDocument) return;
|
||||||
|
|
||||||
|
const positionCache = pagePositionCacheRef.current;
|
||||||
|
const neighborCache = pageNeighborCacheRef.current;
|
||||||
|
const pages = editedDocument.pages;
|
||||||
|
|
||||||
|
pages.forEach((page, index) => {
|
||||||
|
positionCache.set(page.id, index);
|
||||||
|
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
|
||||||
|
});
|
||||||
|
}, [editedDocument]);
|
||||||
|
|
||||||
|
const fileOrderKey = useMemo(() => fileOrder.join(","), [fileOrder]);
|
||||||
|
const mergedDocSignature = useMemo(() => {
|
||||||
|
if (!mergedPdfDocument?.pages) return "";
|
||||||
|
return mergedPdfDocument.pages.map((page) => page.id).join(",");
|
||||||
|
}, [mergedPdfDocument]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mergedPdfDocument) {
|
||||||
|
lastSyncedSignatureRef.current = null;
|
||||||
|
}
|
||||||
|
}, [mergedPdfDocument]);
|
||||||
|
|
||||||
|
// Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing)
|
||||||
|
useEffect(() => {
|
||||||
|
const currentEditedDocument = editedDocumentRef.current;
|
||||||
|
if (!mergedPdfDocument || !currentEditedDocument) return;
|
||||||
|
|
||||||
|
const signatureChanged =
|
||||||
|
mergedDocSignature !== lastSyncedSignatureRef.current;
|
||||||
|
const metadataChanged =
|
||||||
|
currentEditedDocument.id !== mergedPdfDocument.id ||
|
||||||
|
currentEditedDocument.file !== mergedPdfDocument.file ||
|
||||||
|
currentEditedDocument.name !== mergedPdfDocument.name;
|
||||||
|
|
||||||
|
if (!signatureChanged && !metadataChanged) return;
|
||||||
|
|
||||||
|
setEditedDocument((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
|
||||||
|
let pages = prev.pages;
|
||||||
|
|
||||||
|
if (signatureChanged) {
|
||||||
|
const sourcePages = mergedPdfDocument.pages;
|
||||||
|
const sourceIds = new Set(sourcePages.map((p) => p.id));
|
||||||
|
const prevIds = new Set(prev.pages.map((p) => p.id));
|
||||||
|
|
||||||
|
const newPages: PDFPage[] = [];
|
||||||
|
for (const page of sourcePages) {
|
||||||
|
if (!prevIds.has(page.id)) {
|
||||||
|
newPages.push(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAdditions = newPages.length > 0;
|
||||||
|
const isEphemeralPage = (page: PDFPage) =>
|
||||||
|
Boolean(page.isBlankPage || page.isPlaceholder);
|
||||||
|
|
||||||
|
let hasRemovals = false;
|
||||||
|
for (const page of prev.pages) {
|
||||||
|
if (!sourceIds.has(page.id) && !isEphemeralPage(page)) {
|
||||||
|
hasRemovals = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAdditions || hasRemovals) {
|
||||||
|
pages = [...prev.pages];
|
||||||
|
|
||||||
|
const placeholderPositions = new Map<FileId, number>();
|
||||||
|
pages.forEach((page, index) => {
|
||||||
|
if (page.isPlaceholder && page.originalFileId) {
|
||||||
|
placeholderPositions.set(page.originalFileId, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextInsertIndexByFile = new Map(placeholderPositions);
|
||||||
|
|
||||||
|
if (hasRemovals) {
|
||||||
|
pages = pages.filter(
|
||||||
|
(page) => sourceIds.has(page.id) || isEphemeralPage(page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAdditions) {
|
||||||
|
const mergedIndexMap = new Map<string, number>();
|
||||||
|
sourcePages.forEach((page, index) =>
|
||||||
|
mergedIndexMap.set(page.id, index)
|
||||||
|
);
|
||||||
|
|
||||||
|
const additions = newPages
|
||||||
|
.map((page) => ({
|
||||||
|
page,
|
||||||
|
cachedIndex: pagePositionCacheRef.current.get(page.id),
|
||||||
|
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
|
||||||
|
neighborId: pageNeighborCacheRef.current.get(page.id),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIndex = a.cachedIndex ?? a.mergedIndex;
|
||||||
|
const bIndex = b.cachedIndex ?? b.mergedIndex;
|
||||||
|
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||||
|
return a.mergedIndex - b.mergedIndex;
|
||||||
|
});
|
||||||
|
|
||||||
|
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
|
||||||
|
if (pages.some((existing) => existing.id === page.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertIndex: number;
|
||||||
|
const originalFileId = page.originalFileId;
|
||||||
|
const placeholderIndex =
|
||||||
|
originalFileId !== undefined
|
||||||
|
? nextInsertIndexByFile.get(originalFileId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (originalFileId && placeholderIndex !== undefined) {
|
||||||
|
insertIndex = Math.min(placeholderIndex, pages.length);
|
||||||
|
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
|
||||||
|
} else if (neighborId === null) {
|
||||||
|
insertIndex = 0;
|
||||||
|
} else if (neighborId) {
|
||||||
|
const neighborIndex = pages.findIndex((p) => p.id === neighborId);
|
||||||
|
if (neighborIndex !== -1) {
|
||||||
|
insertIndex = neighborIndex + 1;
|
||||||
|
} else {
|
||||||
|
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||||
|
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||||
|
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clonedPage = { ...page };
|
||||||
|
pages.splice(insertIndex, 0, clonedPage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pages = pages.map((page, index) => ({
|
||||||
|
...page,
|
||||||
|
pageNumber: index + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldReplaceBase = metadataChanged || signatureChanged;
|
||||||
|
const baseDocument = shouldReplaceBase
|
||||||
|
? {
|
||||||
|
...mergedPdfDocument,
|
||||||
|
destroy: prev.destroy,
|
||||||
|
}
|
||||||
|
: prev;
|
||||||
|
|
||||||
|
if (baseDocument === prev && pages === prev.pages) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseDocument,
|
||||||
|
pages,
|
||||||
|
totalPages: pages.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signatureChanged) {
|
||||||
|
lastSyncedSignatureRef.current = mergedDocSignature;
|
||||||
|
}
|
||||||
|
}, [mergedPdfDocument, fileOrderKey, mergedDocSignature]);
|
||||||
|
|
||||||
|
const displayDocument = editedDocument || initialDocument;
|
||||||
|
|
||||||
|
const getEditedDocument = useCallback(
|
||||||
|
() => editedDocumentRef.current,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateCurrentPages(displayDocument?.pages ?? null);
|
||||||
|
}, [displayDocument, updateCurrentPages]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
displayDocument,
|
||||||
|
getEditedDocument,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseEditedDocumentStateReturn = ReturnType<
|
||||||
|
typeof useEditedDocumentState
|
||||||
|
>;
|
||||||
@ -0,0 +1,420 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BulkRotateCommand,
|
||||||
|
DeletePagesCommand,
|
||||||
|
PageBreakCommand,
|
||||||
|
ReorderPagesCommand,
|
||||||
|
SplitCommand,
|
||||||
|
} from "@app/components/pageEditor/commands/pageCommands";
|
||||||
|
import type {
|
||||||
|
useFileActions,
|
||||||
|
useFileState,
|
||||||
|
} from "@app/contexts/FileContext";
|
||||||
|
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||||
|
import { FileId } from "@app/types/file";
|
||||||
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
|
|
||||||
|
type FileActions = ReturnType<typeof useFileActions>["actions"];
|
||||||
|
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
|
||||||
|
|
||||||
|
interface UsePageEditorCommandsParams {
|
||||||
|
displayDocument: PDFDocument | null;
|
||||||
|
getEditedDocument: () => PDFDocument | null;
|
||||||
|
setEditedDocument: React.Dispatch<React.SetStateAction<PDFDocument | null>>;
|
||||||
|
splitPositions: Set<number>;
|
||||||
|
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||||
|
selectedPageIds: string[];
|
||||||
|
setSelectedPageIds: (ids: string[]) => void;
|
||||||
|
getPageNumbersFromIds: (pageIds: string[]) => number[];
|
||||||
|
executeCommandWithTracking: (command: any) => void;
|
||||||
|
updateFileOrderFromPages: (pages: PDFPage[]) => void;
|
||||||
|
actions: FileActions;
|
||||||
|
selectors: FileSelectors;
|
||||||
|
setSelectionMode: (enabled: boolean) => void;
|
||||||
|
clearUndoHistory: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePageEditorCommands = ({
|
||||||
|
displayDocument,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
splitPositions,
|
||||||
|
setSplitPositions,
|
||||||
|
selectedPageIds,
|
||||||
|
setSelectedPageIds,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
updateFileOrderFromPages,
|
||||||
|
actions,
|
||||||
|
selectors,
|
||||||
|
setSelectionMode,
|
||||||
|
clearUndoHistory,
|
||||||
|
}: UsePageEditorCommandsParams) => {
|
||||||
|
const closePdf = useCallback(() => {
|
||||||
|
actions.clearAllFiles();
|
||||||
|
clearUndoHistory();
|
||||||
|
setSelectedPageIds([]);
|
||||||
|
setSelectionMode(false);
|
||||||
|
}, [actions, clearUndoHistory, setSelectedPageIds, setSelectionMode]);
|
||||||
|
|
||||||
|
const handleRotatePages = useCallback(
|
||||||
|
(pageIds: string[], rotation: number) => {
|
||||||
|
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||||
|
executeCommandWithTracking(bulkRotateCommand);
|
||||||
|
},
|
||||||
|
[executeCommandWithTracking]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRotateCommand = useCallback(
|
||||||
|
(pageIds: string[], rotation: number) => ({
|
||||||
|
execute: () => {
|
||||||
|
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||||
|
executeCommandWithTracking(bulkRotateCommand);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[executeCommandWithTracking]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createDeleteCommand = useCallback(
|
||||||
|
(pageIds: string[]) => ({
|
||||||
|
execute: () => {
|
||||||
|
const currentDocument = getEditedDocument();
|
||||||
|
if (!currentDocument) return;
|
||||||
|
|
||||||
|
const pagesToDelete = pageIds
|
||||||
|
.map((pageId) => {
|
||||||
|
const page = currentDocument.pages.find((p) => p.id === pageId);
|
||||||
|
return page?.pageNumber || 0;
|
||||||
|
})
|
||||||
|
.filter((num) => num > 0);
|
||||||
|
|
||||||
|
if (pagesToDelete.length > 0) {
|
||||||
|
const deleteCommand = new DeletePagesCommand(
|
||||||
|
pagesToDelete,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
setSelectedPageIds,
|
||||||
|
() => splitPositions,
|
||||||
|
setSplitPositions,
|
||||||
|
() => getPageNumbersFromIds(selectedPageIds),
|
||||||
|
() => closePdf()
|
||||||
|
);
|
||||||
|
executeCommandWithTracking(deleteCommand);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
closePdf,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
getEditedDocument,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
selectedPageIds,
|
||||||
|
setEditedDocument,
|
||||||
|
setSelectedPageIds,
|
||||||
|
setSplitPositions,
|
||||||
|
splitPositions,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createSplitCommand = useCallback(
|
||||||
|
(position: number) => ({
|
||||||
|
execute: () => {
|
||||||
|
const splitCommand = new SplitCommand(
|
||||||
|
position,
|
||||||
|
() => splitPositions,
|
||||||
|
setSplitPositions
|
||||||
|
);
|
||||||
|
executeCommandWithTracking(splitCommand);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[splitPositions, executeCommandWithTracking, setSplitPositions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeCommand = useCallback((command: any) => {
|
||||||
|
if (command && typeof command.execute === "function") {
|
||||||
|
command.execute();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||||
|
|
||||||
|
const deleteCommand = new DeletePagesCommand(
|
||||||
|
selectedPageNumbers,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
setSelectedPageIds,
|
||||||
|
() => splitPositions,
|
||||||
|
setSplitPositions,
|
||||||
|
() => selectedPageNumbers,
|
||||||
|
() => closePdf()
|
||||||
|
);
|
||||||
|
executeCommandWithTracking(deleteCommand);
|
||||||
|
}, [
|
||||||
|
closePdf,
|
||||||
|
displayDocument,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
getEditedDocument,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
selectedPageIds,
|
||||||
|
setEditedDocument,
|
||||||
|
setSelectedPageIds,
|
||||||
|
setSplitPositions,
|
||||||
|
splitPositions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDeletePage = useCallback(
|
||||||
|
(pageNumber: number) => {
|
||||||
|
if (!displayDocument) return;
|
||||||
|
|
||||||
|
const deleteCommand = new DeletePagesCommand(
|
||||||
|
[pageNumber],
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
setSelectedPageIds,
|
||||||
|
() => splitPositions,
|
||||||
|
setSplitPositions,
|
||||||
|
() => getPageNumbersFromIds(selectedPageIds),
|
||||||
|
() => closePdf()
|
||||||
|
);
|
||||||
|
executeCommandWithTracking(deleteCommand);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
closePdf,
|
||||||
|
getEditedDocument,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
selectedPageIds,
|
||||||
|
setEditedDocument,
|
||||||
|
setSelectedPageIds,
|
||||||
|
setSplitPositions,
|
||||||
|
splitPositions,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSplit = useCallback(() => {
|
||||||
|
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
selectedPositions.push(pageIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedPositions.length === 0) return;
|
||||||
|
|
||||||
|
const existingSplitsCount = selectedPositions.filter((pos) =>
|
||||||
|
splitPositions.has(pos)
|
||||||
|
).length;
|
||||||
|
const noSplitsCount = selectedPositions.length - existingSplitsCount;
|
||||||
|
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||||
|
|
||||||
|
const newSplitPositions = new Set(splitPositions);
|
||||||
|
|
||||||
|
if (shouldRemoveSplits) {
|
||||||
|
selectedPositions.forEach((pos) => newSplitPositions.delete(pos));
|
||||||
|
} else {
|
||||||
|
selectedPositions.forEach((pos) => newSplitPositions.add(pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = handleSplit;
|
||||||
|
|
||||||
|
const handlePageBreak = useCallback(() => {
|
||||||
|
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||||
|
|
||||||
|
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||||
|
|
||||||
|
const pageBreakCommand = new PageBreakCommand(
|
||||||
|
selectedPageNumbers,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument
|
||||||
|
);
|
||||||
|
executeCommandWithTracking(pageBreakCommand);
|
||||||
|
}, [
|
||||||
|
displayDocument,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
getEditedDocument,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
selectedPageIds,
|
||||||
|
setEditedDocument,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePageBreakAll = handlePageBreak;
|
||||||
|
|
||||||
|
const handleInsertFiles = useCallback(
|
||||||
|
async (
|
||||||
|
files: File[] | StirlingFileStub[],
|
||||||
|
insertAfterPage: number,
|
||||||
|
isFromStorage?: boolean
|
||||||
|
) => {
|
||||||
|
const workingDocument = getEditedDocument();
|
||||||
|
if (!workingDocument || files.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetPage = workingDocument.pages.find(
|
||||||
|
(p) => p.pageNumber === insertAfterPage
|
||||||
|
);
|
||||||
|
if (!targetPage) return;
|
||||||
|
|
||||||
|
const insertAfterPageId = targetPage.id;
|
||||||
|
let addedFileIds: FileId[] = [];
|
||||||
|
if (isFromStorage) {
|
||||||
|
const stubs = files as StirlingFileStub[];
|
||||||
|
const result = await actions.addStirlingFileStubs(stubs, {
|
||||||
|
selectFiles: true,
|
||||||
|
insertAfterPageId,
|
||||||
|
});
|
||||||
|
addedFileIds = result.map((file) => file.fileId);
|
||||||
|
} else {
|
||||||
|
const result = await actions.addFiles(files as File[], {
|
||||||
|
selectFiles: true,
|
||||||
|
insertAfterPageId,
|
||||||
|
});
|
||||||
|
addedFileIds = result.map((file) => file.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const newPages: PDFPage[] = [];
|
||||||
|
for (const fileId of addedFileIds) {
|
||||||
|
const stub = selectors.getStirlingFileStub(fileId);
|
||||||
|
if (stub?.processedFile?.pages) {
|
||||||
|
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
|
||||||
|
...page,
|
||||||
|
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
|
||||||
|
pageNumber: page.pageNumber ?? idx + 1,
|
||||||
|
originalFileId: fileId,
|
||||||
|
originalPageNumber:
|
||||||
|
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
|
||||||
|
rotation: page.rotation ?? 0,
|
||||||
|
thumbnail: page.thumbnail ?? null,
|
||||||
|
selected: false,
|
||||||
|
splitAfter: page.splitAfter ?? false,
|
||||||
|
}));
|
||||||
|
newPages.push(...clonedPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPages.length > 0) {
|
||||||
|
const targetIndex = workingDocument.pages.findIndex(
|
||||||
|
(p) => p.id === targetPage.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
const updatedPages = [...workingDocument.pages];
|
||||||
|
updatedPages.splice(targetIndex + 1, 0, ...newPages);
|
||||||
|
|
||||||
|
updatedPages.forEach((page, index) => {
|
||||||
|
page.pageNumber = index + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
setEditedDocument({
|
||||||
|
...workingDocument,
|
||||||
|
pages: updatedPages,
|
||||||
|
});
|
||||||
|
|
||||||
|
updateFileOrderFromPages(updatedPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to insert files:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
getEditedDocument,
|
||||||
|
actions,
|
||||||
|
selectors,
|
||||||
|
updateFileOrderFromPages,
|
||||||
|
setEditedDocument,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReorderPages = useCallback(
|
||||||
|
(
|
||||||
|
sourcePageNumber: number,
|
||||||
|
targetIndex: number,
|
||||||
|
draggedPageIds?: string[]
|
||||||
|
) => {
|
||||||
|
if (!displayDocument) return;
|
||||||
|
|
||||||
|
const selectedPages = draggedPageIds
|
||||||
|
? getPageNumbersFromIds(draggedPageIds)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const reorderCommand = new ReorderPagesCommand(
|
||||||
|
sourcePageNumber,
|
||||||
|
targetIndex,
|
||||||
|
selectedPages,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
(newPages) => updateFileOrderFromPages(newPages)
|
||||||
|
);
|
||||||
|
executeCommandWithTracking(reorderCommand);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
displayDocument,
|
||||||
|
getEditedDocument,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
setEditedDocument,
|
||||||
|
updateFileOrderFromPages,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createRotateCommand,
|
||||||
|
createDeleteCommand,
|
||||||
|
createSplitCommand,
|
||||||
|
executeCommand,
|
||||||
|
handleRotate,
|
||||||
|
handleDelete,
|
||||||
|
handleDeletePage,
|
||||||
|
handleSplit,
|
||||||
|
handleSplitAll,
|
||||||
|
handlePageBreak,
|
||||||
|
handlePageBreakAll,
|
||||||
|
handleInsertFiles,
|
||||||
|
handleReorderPages,
|
||||||
|
closePdf,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsePageEditorCommandsReturn = ReturnType<
|
||||||
|
typeof usePageEditorCommands
|
||||||
|
>;
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
import { FileId } from '@app/types/file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintains stable color assignments for a collection of file IDs.
|
||||||
|
* Colors are assigned by insertion order and preserved across reorders.
|
||||||
|
*/
|
||||||
|
export function useFileColorMap(fileIds: FileId[]): Map<FileId, number> {
|
||||||
|
const assignmentsRef = useRef(new Map<FileId, number>());
|
||||||
|
|
||||||
|
const serializedIds = useMemo(() => fileIds.join(','), [fileIds]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const assignments = assignmentsRef.current;
|
||||||
|
const activeIds = new Set(fileIds);
|
||||||
|
|
||||||
|
// Remove colors for files that no longer exist
|
||||||
|
for (const id of Array.from(assignments.keys())) {
|
||||||
|
if (!activeIds.has(id)) {
|
||||||
|
assignments.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign colors to any new files
|
||||||
|
fileIds.forEach((id) => {
|
||||||
|
if (!assignments.has(id)) {
|
||||||
|
assignments.set(id, assignments.size);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
}, [serializedIds, fileIds]);
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
|
||||||
|
import { PDFDocument } from '@app/types/pageEditor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that calls usePageDocument but only returns the FIRST non-null result
|
||||||
|
* After initialization, it ignores all subsequent updates
|
||||||
|
*/
|
||||||
|
export function useInitialPageDocument(): PDFDocument | null {
|
||||||
|
const { document: liveDocument } = usePageDocument();
|
||||||
|
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only set once when we get the first non-null document
|
||||||
|
if (liveDocument && !initialDocument) {
|
||||||
|
console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages');
|
||||||
|
setInitialDocument(liveDocument);
|
||||||
|
}
|
||||||
|
}, [liveDocument, initialDocument]);
|
||||||
|
|
||||||
|
return initialDocument;
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useFileState } from '@app/contexts/FileContext';
|
import { useFileState } from '@app/contexts/FileContext';
|
||||||
|
import { usePageEditor } from '@app/contexts/PageEditorContext';
|
||||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||||
import { FileId } from '@app/types/file';
|
import { FileId } from '@app/types/file';
|
||||||
|
|
||||||
@ -15,14 +16,40 @@ export interface PageDocumentHook {
|
|||||||
*/
|
*/
|
||||||
export function usePageDocument(): PageDocumentHook {
|
export function usePageDocument(): PageDocumentHook {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
const { fileOrder, currentPages } = usePageEditor();
|
||||||
|
|
||||||
// Prefer IDs + selectors to avoid array identity churn
|
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
||||||
const activeFileIds = state.files.ids;
|
// This ensures the page editor respects its own workspace ordering
|
||||||
const primaryFileId = activeFileIds[0] ?? null;
|
const allFileIds = fileOrder;
|
||||||
|
|
||||||
// Stable signature for effects (prevents loops)
|
// Derive selected file IDs directly from FileContext (single source of truth)
|
||||||
|
// Filter to only include PDF files (PageEditor only supports PDFs)
|
||||||
|
// Use stable string keys to prevent infinite loops
|
||||||
|
const allFileIdsKey = allFileIds.join(',');
|
||||||
|
const selectedFileIdsKey = [...state.ui.selectedFileIds].sort().join(',');
|
||||||
const activeFilesSignature = selectors.getFilesSignature();
|
const activeFilesSignature = selectors.getFilesSignature();
|
||||||
|
|
||||||
|
// Get ALL PDF files (selected or not) for document building with placeholders
|
||||||
|
const activeFileIds = useMemo(() => {
|
||||||
|
return allFileIds.filter(id => {
|
||||||
|
const stub = selectors.getStirlingFileStub(id);
|
||||||
|
return stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
|
||||||
|
});
|
||||||
|
}, [allFileIdsKey, activeFilesSignature, selectors]);
|
||||||
|
|
||||||
|
const selectedActiveFileIds = useMemo(() => {
|
||||||
|
if (activeFileIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const selectedSet = new Set(state.ui.selectedFileIds);
|
||||||
|
if (selectedSet.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return activeFileIds.filter((id) => selectedSet.has(id));
|
||||||
|
}, [activeFileIds, selectedFileIdsKey]);
|
||||||
|
|
||||||
|
const primaryFileId = selectedActiveFileIds[0] ?? activeFileIds[0] ?? null;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const globalProcessing = state.ui.isProcessing;
|
const globalProcessing = state.ui.isProcessing;
|
||||||
|
|
||||||
@ -32,6 +59,10 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||||
|
|
||||||
// Compute merged document with stable signature (prevents infinite loops)
|
// Compute merged document with stable signature (prevents infinite loops)
|
||||||
|
const currentPagesSignature = useMemo(() => {
|
||||||
|
return currentPages ? currentPages.map(page => page.id).join(',') : '';
|
||||||
|
}, [currentPages]);
|
||||||
|
|
||||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||||
if (activeFileIds.length === 0) return null;
|
if (activeFileIds.length === 0) return null;
|
||||||
|
|
||||||
@ -43,10 +74,14 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
activeFileIds.length === 1
|
namingFileIds.length <= 1
|
||||||
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
? (namingFileIds[0]
|
||||||
: activeFileIds
|
? selectors.getStirlingFileStub(namingFileIds[0])?.name ?? 'document.pdf'
|
||||||
|
: 'document.pdf')
|
||||||
|
: namingFileIds
|
||||||
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||||
.join(' + ');
|
.join(' + ');
|
||||||
|
|
||||||
@ -69,13 +104,28 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
// Build pages by interleaving original pages with insertions
|
// Build pages by interleaving original pages with insertions
|
||||||
let pages: PDFPage[] = [];
|
let pages: PDFPage[] = [];
|
||||||
|
|
||||||
// Helper function to create pages from a file
|
// Helper function to create pages from a file (or placeholder if deselected)
|
||||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
|
||||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||||
if (!stirlingFileStub) {
|
if (!stirlingFileStub) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If file is deselected, create a single placeholder page
|
||||||
|
if (!isSelected) {
|
||||||
|
return [{
|
||||||
|
id: `${fileId}-placeholder`,
|
||||||
|
pageNumber: startPageNumber,
|
||||||
|
originalPageNumber: 1,
|
||||||
|
originalFileId: fileId,
|
||||||
|
rotation: 0,
|
||||||
|
thumbnail: null,
|
||||||
|
selected: false,
|
||||||
|
splitAfter: false,
|
||||||
|
isPlaceholder: true,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
const processedFile = stirlingFileStub.processedFile;
|
const processedFile = stirlingFileStub.processedFile;
|
||||||
let filePages: PDFPage[] = [];
|
let filePages: PDFPage[] = [];
|
||||||
|
|
||||||
@ -90,6 +140,7 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
splitAfter: page.splitAfter || false,
|
splitAfter: page.splitAfter || false,
|
||||||
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
|
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
|
||||||
originalFileId: fileId,
|
originalFileId: fileId,
|
||||||
|
isPlaceholder: false,
|
||||||
}));
|
}));
|
||||||
} else if (processedFile?.totalPages) {
|
} else if (processedFile?.totalPages) {
|
||||||
// Fallback: create pages without thumbnails but with correct count
|
// Fallback: create pages without thumbnails but with correct count
|
||||||
@ -102,16 +153,30 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
selected: false,
|
selected: false,
|
||||||
splitAfter: false,
|
splitAfter: false,
|
||||||
|
isPlaceholder: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return filePages;
|
return filePages;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collect all pages from original files (without renumbering yet)
|
// Collect all pages from original files, respecting their previous positions
|
||||||
|
const selectedFileIdsSet = new Set(state.ui.selectedFileIds);
|
||||||
|
|
||||||
|
// Sort original files by their position in fileOrder (so placeholders stay in correct spot)
|
||||||
|
// Use fileOrder as source of truth since it persists across page editor sessions
|
||||||
|
const fileOrderMap = new Map(allFileIds.map((id, index) => [id, index]));
|
||||||
|
|
||||||
|
const sortedOriginalFileIds = [...originalFileIds].sort((a, b) => {
|
||||||
|
const posA = fileOrderMap.get(a) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const posB = fileOrderMap.get(b) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return posA - posB;
|
||||||
|
});
|
||||||
|
|
||||||
const originalFilePages: PDFPage[] = [];
|
const originalFilePages: PDFPage[] = [];
|
||||||
originalFileIds.forEach(fileId => {
|
sortedOriginalFileIds.forEach(fileId => {
|
||||||
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
|
const isSelected = selectedFileIdsSet.has(fileId);
|
||||||
|
const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering
|
||||||
originalFilePages.push(...filePages);
|
originalFilePages.push(...filePages);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -130,7 +195,8 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
// Collect all pages to insert
|
// Collect all pages to insert
|
||||||
const allNewPages: PDFPage[] = [];
|
const allNewPages: PDFPage[] = [];
|
||||||
fileIds.forEach(fileId => {
|
fileIds.forEach(fileId => {
|
||||||
const insertedPages = createPagesFromFile(fileId, 1);
|
const isSelected = selectedFileIdsSet.has(fileId);
|
||||||
|
const insertedPages = createPagesFromFile(fileId, 1, isSelected);
|
||||||
allNewPages.push(...insertedPages);
|
allNewPages.push(...insertedPages);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,6 +213,29 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pages are already in the correct order from the sorted assembly above
|
||||||
|
// Just ensure page numbers are sequential
|
||||||
|
pages = pages.map((page, index) => ({
|
||||||
|
...page,
|
||||||
|
pageNumber: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentPagesSet = currentPages ? new Set(currentPages.map(page => page.id)) : null;
|
||||||
|
if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) {
|
||||||
|
const sameIds = pages.every(page => currentPagesSet.has(page.id));
|
||||||
|
if (sameIds) {
|
||||||
|
const mergedById = new Map(pages.map(page => [page.id, page]));
|
||||||
|
pages = currentPages.map((currentPage, index) => {
|
||||||
|
const source = mergedById.get(currentPage.id);
|
||||||
|
const mergedPage = source ? { ...source, ...currentPage } : currentPage;
|
||||||
|
return {
|
||||||
|
...mergedPage,
|
||||||
|
pageNumber: index + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mergedDoc: PDFDocument = {
|
const mergedDoc: PDFDocument = {
|
||||||
id: activeFileIds.join('-'),
|
id: activeFileIds.join('-'),
|
||||||
name,
|
name,
|
||||||
@ -156,7 +245,7 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return mergedDoc;
|
return mergedDoc;
|
||||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
|
}, [activeFileIds, selectedActiveFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, allFileIds, currentPagesSignature, currentPages]);
|
||||||
|
|
||||||
// Large document detection for smart loading
|
// Large document detection for smart loading
|
||||||
const isVeryLargeDocument = useMemo(() => {
|
const isVeryLargeDocument = useMemo(() => {
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { usePageEditor } from '@app/contexts/PageEditorContext';
|
||||||
|
import { useFileState } from '@app/contexts/FileContext';
|
||||||
|
import { FileId } from '@app/types/file';
|
||||||
|
import { useFileColorMap } from '@app/components/pageEditor/hooks/useFileColorMap';
|
||||||
|
|
||||||
|
export interface PageEditorDropdownFile {
|
||||||
|
fileId: FileId;
|
||||||
|
name: string;
|
||||||
|
versionNumber?: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageEditorDropdownState {
|
||||||
|
files: PageEditorDropdownFile[];
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
onToggleSelection: (fileId: FileId) => void;
|
||||||
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||||
|
fileColorMap: Map<FileId, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPdf = (name?: string | null) =>
|
||||||
|
typeof name === 'string' && name.toLowerCase().endsWith('.pdf');
|
||||||
|
|
||||||
|
export function usePageEditorDropdownState(): PageEditorDropdownState {
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
const {
|
||||||
|
toggleFileSelection,
|
||||||
|
reorderFiles,
|
||||||
|
fileOrder,
|
||||||
|
} = usePageEditor();
|
||||||
|
|
||||||
|
const pageEditorFiles = useMemo(() => {
|
||||||
|
return fileOrder
|
||||||
|
.map<PageEditorDropdownFile | null>((fileId) => {
|
||||||
|
const stub = selectors.getStirlingFileStub(fileId);
|
||||||
|
if (!isPdf(stub?.name)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileId,
|
||||||
|
name: stub?.name || '',
|
||||||
|
versionNumber: stub?.versionNumber,
|
||||||
|
isSelected: state.ui.selectedFileIds.includes(fileId),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((file): file is PageEditorDropdownFile => file !== null);
|
||||||
|
}, [fileOrder, selectors, state.ui.selectedFileIds]);
|
||||||
|
|
||||||
|
const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId));
|
||||||
|
|
||||||
|
const selectedCount = useMemo(
|
||||||
|
() => pageEditorFiles.filter((file) => file.isSelected).length,
|
||||||
|
[pageEditorFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo<PageEditorDropdownState>(() => ({
|
||||||
|
files: pageEditorFiles,
|
||||||
|
selectedCount,
|
||||||
|
totalCount: pageEditorFiles.length,
|
||||||
|
onToggleSelection: toggleFileSelection,
|
||||||
|
onReorder: reorderFiles,
|
||||||
|
fileColorMap,
|
||||||
|
}), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]);
|
||||||
|
}
|
||||||
@ -0,0 +1,313 @@
|
|||||||
|
import { Dispatch, SetStateAction, useCallback } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
useFileActions,
|
||||||
|
useFileState,
|
||||||
|
} from "@app/contexts/FileContext";
|
||||||
|
import { documentManipulationService } from "@app/services/documentManipulationService";
|
||||||
|
import { pdfExportService } from "@app/services/pdfExportService";
|
||||||
|
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
|
||||||
|
import { FileId } from "@app/types/file";
|
||||||
|
import { PDFDocument } from "@app/types/pageEditor";
|
||||||
|
|
||||||
|
type FileActions = ReturnType<typeof useFileActions>["actions"];
|
||||||
|
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
|
||||||
|
|
||||||
|
interface UsePageEditorExportParams {
|
||||||
|
displayDocument: PDFDocument | null;
|
||||||
|
selectedPageIds: string[];
|
||||||
|
splitPositions: Set<number>;
|
||||||
|
selectedFileIds: FileId[];
|
||||||
|
selectors: FileSelectors;
|
||||||
|
actions: FileActions;
|
||||||
|
setHasUnsavedChanges: (dirty: boolean) => void;
|
||||||
|
exportLoading: boolean;
|
||||||
|
setExportLoading: (loading: boolean) => void;
|
||||||
|
setSplitPositions: Dispatch<SetStateAction<Set<number>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePlaceholderPages = (document: PDFDocument): PDFDocument => {
|
||||||
|
const filteredPages = document.pages.filter((page) => !page.isPlaceholder);
|
||||||
|
if (filteredPages.length === document.pages.length) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPages = filteredPages.map((page, index) => ({
|
||||||
|
...page,
|
||||||
|
pageNumber: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
pages: normalizedPages,
|
||||||
|
totalPages: normalizedPages.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeProcessedDocuments = (
|
||||||
|
processed: PDFDocument | PDFDocument[]
|
||||||
|
): PDFDocument | PDFDocument[] => {
|
||||||
|
if (Array.isArray(processed)) {
|
||||||
|
const normalized = processed
|
||||||
|
.map(removePlaceholderPages)
|
||||||
|
.filter((doc) => doc.pages.length > 0);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return removePlaceholderPages(processed);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePageEditorExport = ({
|
||||||
|
displayDocument,
|
||||||
|
selectedPageIds,
|
||||||
|
splitPositions,
|
||||||
|
selectedFileIds,
|
||||||
|
selectors,
|
||||||
|
actions,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
exportLoading,
|
||||||
|
setExportLoading,
|
||||||
|
setSplitPositions,
|
||||||
|
}: UsePageEditorExportParams) => {
|
||||||
|
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||||
|
const sourceFiles = new Map<FileId, File>();
|
||||||
|
|
||||||
|
selectedFileIds.forEach((fileId) => {
|
||||||
|
const file = selectors.getFile(fileId);
|
||||||
|
if (file) {
|
||||||
|
sourceFiles.set(fileId, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasInsertedFiles = false;
|
||||||
|
const hasMultipleOriginalFiles = selectedFileIds.length > 1;
|
||||||
|
|
||||||
|
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceFiles.size > 0 ? sourceFiles : null;
|
||||||
|
}, [selectedFileIds, selectors]);
|
||||||
|
|
||||||
|
const getExportFilename = useCallback((): string => {
|
||||||
|
if (selectedFileIds.length <= 1) {
|
||||||
|
return displayDocument?.name || "document.pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstFile = selectors.getFile(selectedFileIds[0]);
|
||||||
|
if (firstFile) {
|
||||||
|
const baseName = firstFile.name.replace(/\.pdf$/i, "");
|
||||||
|
return `${baseName} (merged).pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "merged-document.pdf";
|
||||||
|
}, [selectedFileIds, selectors, displayDocument]);
|
||||||
|
|
||||||
|
const onExportSelected = useCallback(async () => {
|
||||||
|
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||||
|
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
const processedDocuments =
|
||||||
|
documentManipulationService.applyDOMChangesToDocument(
|
||||||
|
displayDocument,
|
||||||
|
displayDocument,
|
||||||
|
splitPositions
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||||
|
const documentWithDOMState = Array.isArray(normalizedDocuments)
|
||||||
|
? normalizedDocuments[0]
|
||||||
|
: normalizedDocuments;
|
||||||
|
|
||||||
|
if (!documentWithDOMState || documentWithDOMState.pages.length === 0) {
|
||||||
|
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
||||||
|
setExportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSelectedPageIds = selectedPageIds.filter((pageId) =>
|
||||||
|
documentWithDOMState.pages.some((page) => page.id === pageId)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
pdfExportService.downloadFile(result.blob, result.filename);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setSplitPositions(new Set());
|
||||||
|
setExportLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export failed:", error);
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
displayDocument,
|
||||||
|
selectedPageIds,
|
||||||
|
splitPositions,
|
||||||
|
getSourceFiles,
|
||||||
|
getExportFilename,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
setExportLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onExportAll = useCallback(async () => {
|
||||||
|
if (!displayDocument) return;
|
||||||
|
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
const processedDocuments =
|
||||||
|
documentManipulationService.applyDOMChangesToDocument(
|
||||||
|
displayDocument,
|
||||||
|
displayDocument,
|
||||||
|
splitPositions
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
||||||
|
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
||||||
|
) {
|
||||||
|
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
||||||
|
setExportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFiles = getSourceFiles();
|
||||||
|
const exportFilename = getExportFilename();
|
||||||
|
const files = await exportProcessedDocumentsToFiles(
|
||||||
|
normalizedDocuments,
|
||||||
|
sourceFiles,
|
||||||
|
exportFilename
|
||||||
|
);
|
||||||
|
|
||||||
|
if (files.length > 1) {
|
||||||
|
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 zipFilename = exportFilename.replace(/\.pdf$/i, ".zip");
|
||||||
|
|
||||||
|
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||||
|
} else {
|
||||||
|
const file = files[0];
|
||||||
|
pdfExportService.downloadFile(file, file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setSplitPositions(new Set());
|
||||||
|
setExportLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export failed:", error);
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
displayDocument,
|
||||||
|
splitPositions,
|
||||||
|
getSourceFiles,
|
||||||
|
getExportFilename,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
setExportLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applyChanges = useCallback(async () => {
|
||||||
|
if (!displayDocument) return;
|
||||||
|
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
const processedDocuments =
|
||||||
|
documentManipulationService.applyDOMChangesToDocument(
|
||||||
|
displayDocument,
|
||||||
|
displayDocument,
|
||||||
|
splitPositions
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
||||||
|
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
||||||
|
) {
|
||||||
|
console.warn("Apply changes skipped: no concrete pages available after filtering placeholders.");
|
||||||
|
setExportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFiles = getSourceFiles();
|
||||||
|
const exportFilename = getExportFilename();
|
||||||
|
const files = await exportProcessedDocumentsToFiles(
|
||||||
|
normalizedDocuments,
|
||||||
|
sourceFiles,
|
||||||
|
exportFilename
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add "_multitool" suffix to filenames
|
||||||
|
const renamedFiles = files.map(file => {
|
||||||
|
const nameParts = file.name.match(/^(.+?)(\.pdf)$/i);
|
||||||
|
if (nameParts) {
|
||||||
|
const baseName = nameParts[1];
|
||||||
|
const extension = nameParts[2];
|
||||||
|
const newName = `${baseName}_multitool${extension}`;
|
||||||
|
return new File([file], newName, { type: file.type });
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store source file IDs before adding new files
|
||||||
|
const sourceFileIds = [...selectedFileIds];
|
||||||
|
|
||||||
|
const newStirlingFiles = await actions.addFiles(renamedFiles, {
|
||||||
|
selectFiles: true,
|
||||||
|
});
|
||||||
|
if (newStirlingFiles.length > 0) {
|
||||||
|
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove source files from context
|
||||||
|
if (sourceFileIds.length > 0) {
|
||||||
|
await actions.removeFiles(sourceFileIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setSplitPositions(new Set());
|
||||||
|
setExportLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Apply changes failed:", error);
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
displayDocument,
|
||||||
|
splitPositions,
|
||||||
|
getSourceFiles,
|
||||||
|
getExportFilename,
|
||||||
|
actions,
|
||||||
|
selectedFileIds,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
setExportLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportLoading,
|
||||||
|
onExportSelected,
|
||||||
|
onExportAll,
|
||||||
|
applyChanges,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsePageEditorExportReturn = ReturnType<typeof usePageEditorExport>;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
export interface PageEditorState {
|
export interface PageEditorState {
|
||||||
// Selection state
|
// Selection state
|
||||||
@ -20,7 +21,7 @@ export interface PageEditorState {
|
|||||||
setSelectedPageIds: (pages: string[]) => void;
|
setSelectedPageIds: (pages: string[]) => void;
|
||||||
setMovingPage: (pageNumber: number | null) => void;
|
setMovingPage: (pageNumber: number | null) => void;
|
||||||
setIsAnimating: (animating: boolean) => void;
|
setIsAnimating: (animating: boolean) => void;
|
||||||
setSplitPositions: (positions: Set<number>) => void;
|
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||||
setExportLoading: (loading: boolean) => void;
|
setExportLoading: (loading: boolean) => void;
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|||||||
@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { PDFDocument } from "@app/types/pageEditor";
|
||||||
|
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
|
||||||
|
|
||||||
|
interface UsePageSelectionManagerParams {
|
||||||
|
displayDocument: PDFDocument | null;
|
||||||
|
selectedPageIds: string[];
|
||||||
|
setSelectedPageIds: (ids: string[]) => void;
|
||||||
|
setSelectionMode: (enabled: boolean) => void;
|
||||||
|
toggleSelectAll: (ids: string[]) => void;
|
||||||
|
activeFilesSignature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePageSelectionManager = ({
|
||||||
|
displayDocument,
|
||||||
|
selectedPageIds,
|
||||||
|
setSelectedPageIds,
|
||||||
|
setSelectionMode,
|
||||||
|
toggleSelectAll,
|
||||||
|
activeFilesSignature,
|
||||||
|
}: UsePageSelectionManagerParams) => {
|
||||||
|
const [csvInput, setCsvInput] = useState<string>("");
|
||||||
|
const hasInitializedSelection = useRef(false);
|
||||||
|
const previousPageIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const totalPages = displayDocument?.pages.length ?? 0;
|
||||||
|
|
||||||
|
const getPageNumbersFromIds = useCallback(
|
||||||
|
(pageIds: string[]) => {
|
||||||
|
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[]) => {
|
||||||
|
if (!displayDocument) return [];
|
||||||
|
return pageNumbers
|
||||||
|
.map((num) => {
|
||||||
|
const page = displayDocument.pages.find((p) => p.pageNumber === num);
|
||||||
|
return page?.id || "";
|
||||||
|
})
|
||||||
|
.filter((id) => id !== "");
|
||||||
|
},
|
||||||
|
[displayDocument]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
displayDocument &&
|
||||||
|
displayDocument.pages.length > 0 &&
|
||||||
|
!hasInitializedSelection.current
|
||||||
|
) {
|
||||||
|
const allPageIds = displayDocument.pages.map((page) => page.id);
|
||||||
|
setSelectedPageIds(allPageIds);
|
||||||
|
setSelectionMode(true);
|
||||||
|
hasInitializedSelection.current = true;
|
||||||
|
}
|
||||||
|
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!displayDocument || displayDocument.pages.length === 0) {
|
||||||
|
previousPageIdsRef.current = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIds = new Set(displayDocument.pages.map((page) => page.id));
|
||||||
|
const newlyAddedPageIds: string[] = [];
|
||||||
|
currentIds.forEach((id) => {
|
||||||
|
if (!previousPageIdsRef.current.has(id)) {
|
||||||
|
newlyAddedPageIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newlyAddedPageIds.length > 0) {
|
||||||
|
const next = new Set(selectedPageIds);
|
||||||
|
newlyAddedPageIds.forEach((id) => next.add(id));
|
||||||
|
setSelectedPageIds(Array.from(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPageIdsRef.current = currentIds;
|
||||||
|
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCsvInput("");
|
||||||
|
}, [activeFilesSignature]);
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
if (!displayDocument) return;
|
||||||
|
const allPageIds = displayDocument.pages.map((page) => page.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]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
csvInput,
|
||||||
|
setCsvInput,
|
||||||
|
totalPages,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
getPageIdsFromNumbers,
|
||||||
|
handleSelectAll,
|
||||||
|
handleDeselectAll,
|
||||||
|
handleSetSelectedPages,
|
||||||
|
updatePagesFromCSV,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsePageSelectionManagerReturn = ReturnType<
|
||||||
|
typeof usePageSelectionManager
|
||||||
|
>;
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { UndoManager } from "@app/components/pageEditor/commands/pageCommands";
|
||||||
|
|
||||||
|
interface UseUndoManagerStateParams {
|
||||||
|
setHasUnsavedChanges: (dirty: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUndoManagerState = ({
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
}: UseUndoManagerStateParams) => {
|
||||||
|
const undoManagerRef = useRef(new UndoManager());
|
||||||
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
|
|
||||||
|
const updateUndoRedoState = useCallback(() => {
|
||||||
|
const undoManager = undoManagerRef.current;
|
||||||
|
setCanUndo(undoManager.canUndo());
|
||||||
|
setCanRedo(undoManager.canRedo());
|
||||||
|
|
||||||
|
if (!undoManager.hasHistory()) {
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}
|
||||||
|
}, [setHasUnsavedChanges]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
|
||||||
|
updateUndoRedoState();
|
||||||
|
}, [updateUndoRedoState]);
|
||||||
|
|
||||||
|
const executeCommandWithTracking = useCallback(
|
||||||
|
(command: any) => {
|
||||||
|
undoManagerRef.current.executeCommand(command);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
},
|
||||||
|
[setHasUnsavedChanges]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
undoManagerRef.current.undo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
undoManagerRef.current.redo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearUndoHistory = useCallback(() => {
|
||||||
|
undoManagerRef.current.clear();
|
||||||
|
updateUndoRedoState();
|
||||||
|
}, [updateUndoRedoState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
handleUndo,
|
||||||
|
handleRedo,
|
||||||
|
clearUndoHistory,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseUndoManagerStateReturn = ReturnType<typeof useUndoManagerState>;
|
||||||
@ -16,6 +16,7 @@ interface PageEditorRightRailButtonsParams {
|
|||||||
handleDeselectAll: () => void;
|
handleDeselectAll: () => void;
|
||||||
handleDelete: () => void;
|
handleDelete: () => void;
|
||||||
onExportSelected: () => void;
|
onExportSelected: () => void;
|
||||||
|
onSaveChanges: () => void;
|
||||||
exportLoading: boolean;
|
exportLoading: boolean;
|
||||||
activeFileCount: number;
|
activeFileCount: number;
|
||||||
closePdf: () => void;
|
closePdf: () => void;
|
||||||
@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
handleDeselectAll,
|
handleDeselectAll,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
onExportSelected,
|
onExportSelected,
|
||||||
|
onSaveChanges,
|
||||||
exportLoading,
|
exportLoading,
|
||||||
activeFileCount,
|
activeFileCount,
|
||||||
closePdf,
|
closePdf,
|
||||||
@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
||||||
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
||||||
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
||||||
|
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
||||||
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
||||||
|
|
||||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||||
@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
visible: totalPages > 0,
|
visible: totalPages > 0,
|
||||||
onClick: onExportSelected,
|
onClick: onExportSelected,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'page-save-changes',
|
||||||
|
icon: <LocalIcon icon="save" width="1.5rem" height="1.5rem" />,
|
||||||
|
tooltip: saveChangesLabel,
|
||||||
|
ariaLabel: saveChangesLabel,
|
||||||
|
section: 'top' as const,
|
||||||
|
order: 55,
|
||||||
|
disabled: totalPages === 0 || exportLoading,
|
||||||
|
visible: totalPages > 0,
|
||||||
|
onClick: onSaveChanges,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'page-close-pdf',
|
id: 'page-close-pdf',
|
||||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
selectByNumberLabel,
|
selectByNumberLabel,
|
||||||
deleteSelectedLabel,
|
deleteSelectedLabel,
|
||||||
exportSelectedLabel,
|
exportSelectedLabel,
|
||||||
|
saveChangesLabel,
|
||||||
closePdfLabel,
|
closePdfLabel,
|
||||||
totalPages,
|
totalPages,
|
||||||
selectedPageCount,
|
selectedPageCount,
|
||||||
@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
handleDeselectAll,
|
handleDeselectAll,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
onExportSelected,
|
onExportSelected,
|
||||||
|
onSaveChanges,
|
||||||
exportLoading,
|
exportLoading,
|
||||||
activeFileCount,
|
activeFileCount,
|
||||||
closePdf,
|
closePdf,
|
||||||
|
|||||||
@ -68,8 +68,6 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
|||||||
const isAdmin = config?.isAdmin ?? false;
|
const isAdmin = config?.isAdmin ?? false;
|
||||||
const runningEE = config?.runningEE ?? false;
|
const runningEE = config?.runningEE ?? false;
|
||||||
|
|
||||||
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
|
|
||||||
|
|
||||||
// Left navigation structure and icons
|
// Left navigation structure and icons
|
||||||
const configNavSections = useMemo(() =>
|
const configNavSections = useMemo(() =>
|
||||||
createConfigNavSections(
|
createConfigNavSections(
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
z-index: 30;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||||
import styles from '@app/components/shared/HoverActionMenu.module.css';
|
import styles from '@app/components/shared/HoverActionMenu.module.css';
|
||||||
|
import { Z_INDEX_HOVER_ACTION_MENU } from '@app/styles/zIndex';
|
||||||
|
|
||||||
export interface HoverAction {
|
export interface HoverAction {
|
||||||
id: string;
|
id: string;
|
||||||
@ -34,7 +35,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
|
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
|
||||||
style={{ opacity: show ? 1 : 0 }}
|
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onMouseUp={(e) => e.stopPropagation()}
|
onMouseUp={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@ -44,10 +45,10 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="md"
|
size="md"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
c={action.color}
|
c={action.color}
|
||||||
|
style={{ color: action.color || 'var(--right-rail-icon)' }}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
234
frontend/src/core/components/shared/PageEditorFileDropdown.tsx
Normal file
234
frontend/src/core/components/shared/PageEditorFileDropdown.tsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
|
||||||
|
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import FitText from '@app/components/shared/FitText';
|
||||||
|
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
||||||
|
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||||
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
|
import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop';
|
||||||
|
|
||||||
|
import { FileId } from '@app/types/file';
|
||||||
|
|
||||||
|
// Local interface for PageEditor file display
|
||||||
|
interface PageEditorFile {
|
||||||
|
fileId: FileId;
|
||||||
|
name: string;
|
||||||
|
versionNumber?: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileMenuItemProps {
|
||||||
|
file: PageEditorFile;
|
||||||
|
index: number;
|
||||||
|
colorIndex: number;
|
||||||
|
onToggleSelection: (fileId: FileId) => void;
|
||||||
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileMenuItem: React.FC<FileMenuItemProps> = ({
|
||||||
|
file,
|
||||||
|
index,
|
||||||
|
colorIndex,
|
||||||
|
onToggleSelection,
|
||||||
|
onReorder,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
itemRef,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
dropPosition,
|
||||||
|
movedRef,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
} = useFileItemDragDrop({
|
||||||
|
fileId: file.fileId,
|
||||||
|
index,
|
||||||
|
onReorder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemName = file?.name || 'Untitled';
|
||||||
|
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
|
||||||
|
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drop indicator line */}
|
||||||
|
{isDragOver && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '4px',
|
||||||
|
backgroundColor: 'rgb(59, 130, 246)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={itemRef}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (movedRef.current) return; // ignore click after drag
|
||||||
|
onToggleSelection(file.fileId);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 0.75rem',
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||||
|
borderLeft: `6px solid ${fileColorBorder}`,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isDragging) {
|
||||||
|
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
|
||||||
|
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isDragging) {
|
||||||
|
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
|
||||||
|
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="xs" style={{ width: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
cursor: 'grab',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'var(--mantine-color-dimmed)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DragIndicatorIcon fontSize="small" />
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={file.isSelected}
|
||||||
|
onChange={() => onToggleSelection(file.fileId)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||||
|
<PrivateContent>
|
||||||
|
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||||
|
</PrivateContent>
|
||||||
|
</div>
|
||||||
|
{file.versionNumber && file.versionNumber > 1 && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
v{file.versionNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageEditorFileDropdownProps {
|
||||||
|
files: PageEditorFile[];
|
||||||
|
onToggleSelection: (fileId: FileId) => void;
|
||||||
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||||
|
switchingTo?: string | null;
|
||||||
|
viewOptionStyle: React.CSSProperties;
|
||||||
|
fileColorMap: Map<string, number>;
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||||
|
files,
|
||||||
|
onToggleSelection,
|
||||||
|
onReorder,
|
||||||
|
switchingTo,
|
||||||
|
viewOptionStyle,
|
||||||
|
fileColorMap,
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
}) => {
|
||||||
|
const { openFilesModal } = useFilesModalContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu trigger="click" position="bottom" width="40rem">
|
||||||
|
<Menu.Target>
|
||||||
|
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||||
|
{switchingTo === "pageEditor" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
|
||||||
|
)}
|
||||||
|
<span className="ph-no-capture">{selectedCount}/{totalCount} files selected</span>
|
||||||
|
<KeyboardArrowDownIcon fontSize="small" />
|
||||||
|
</div>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown className="ph-no-capture" 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: '80vh',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
{files.map((file, index) => {
|
||||||
|
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileMenuItem
|
||||||
|
key={file.fileId}
|
||||||
|
file={file}
|
||||||
|
index={index}
|
||||||
|
colorIndex={colorIndex}
|
||||||
|
onToggleSelection={onToggleSelection}
|
||||||
|
onReorder={onReorder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add File Button */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openFilesModal();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 0.75rem',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderTop: '1px solid var(--border-subtle)',
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="xs" style={{ width: '100%' }}>
|
||||||
|
<AddIcon fontSize="small" style={{ color: 'var(--mantine-color-text)' }} />
|
||||||
|
<Text size="sm" fw={500} style={{ color: 'var(--mantine-color-text)' }} className="ph-no-capture">
|
||||||
|
Add File
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -177,6 +177,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
|
|
||||||
// Moving into the tooltip → keep open
|
// Moving into the tooltip → keep open
|
||||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||||
|
|
||||||
(children.props as any)?.onPointerLeave?.(e);
|
(children.props as any)?.onPointerLeave?.(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +1,49 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
import { SegmentedControl, Loader } from "@mantine/core";
|
import { SegmentedControl, Loader } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
|
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||||
|
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
||||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||||
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
|
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
|
||||||
|
|
||||||
|
|
||||||
const viewOptionStyle: React.CSSProperties = {
|
const viewOptionStyle: React.CSSProperties = {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: '0.5rem',
|
||||||
whiteSpace: 'nowrap',
|
justifyContent: 'center',
|
||||||
paddingTop: '0.3rem',
|
padding: '2px 1rem',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to create view options for SegmentedControl
|
||||||
// Build view options showing text always
|
|
||||||
const createViewOptions = (
|
const createViewOptions = (
|
||||||
currentView: WorkbenchType,
|
currentView: WorkbenchType,
|
||||||
switchingTo: WorkbenchType | null,
|
switchingTo: WorkbenchType | null,
|
||||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||||
currentFileIndex: number,
|
currentFileIndex: number,
|
||||||
onFileSelect?: (index: number) => void,
|
onFileSelect?: (index: number) => void,
|
||||||
|
pageEditorState?: PageEditorDropdownState,
|
||||||
customViews?: CustomWorkbenchViewInstance[]
|
customViews?: CustomWorkbenchViewInstance[]
|
||||||
) => {
|
) => {
|
||||||
|
// Viewer dropdown logic
|
||||||
const currentFile = activeFiles[currentFileIndex];
|
const currentFile = activeFiles[currentFileIndex];
|
||||||
const isInViewer = currentView === 'viewer';
|
const isInViewer = currentView === 'viewer';
|
||||||
const fileName = currentFile?.name || '';
|
const fileName = currentFile?.name || '';
|
||||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||||
const hasMultipleFiles = activeFiles.length > 1;
|
const hasMultipleFiles = activeFiles.length > 1;
|
||||||
const showDropdown = isInViewer && hasMultipleFiles;
|
const showViewerDropdown = isInViewer && hasMultipleFiles;
|
||||||
|
|
||||||
const viewerOption = {
|
const viewerOption = {
|
||||||
label: showDropdown ? (
|
label: showViewerDropdown ? (
|
||||||
<FileDropdownMenu
|
<FileDropdownMenu
|
||||||
displayName={displayName}
|
displayName={viewerDisplayName}
|
||||||
activeFiles={activeFiles}
|
activeFiles={activeFiles}
|
||||||
currentFileIndex={currentFileIndex}
|
currentFileIndex={currentFileIndex}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
@ -51,29 +53,38 @@ const createViewOptions = (
|
|||||||
) : (
|
) : (
|
||||||
<div style={viewOptionStyle}>
|
<div style={viewOptionStyle}>
|
||||||
{switchingTo === "viewer" ? (
|
{switchingTo === "viewer" ? (
|
||||||
<Loader size="xs" />
|
<Loader size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<VisibilityIcon fontSize="small" />
|
<VisibilityIcon fontSize="medium" />
|
||||||
)}
|
)}
|
||||||
<PrivateContent>{displayName}</PrivateContent>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
value: "viewer",
|
value: "viewer",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Page Editor dropdown logic
|
||||||
|
const isInPageEditor = currentView === 'pageEditor';
|
||||||
|
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
|
||||||
|
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
|
||||||
|
|
||||||
const pageEditorOption = {
|
const pageEditorOption = {
|
||||||
label: (
|
label: showPageEditorDropdown ? (
|
||||||
|
<PageEditorFileDropdown
|
||||||
|
files={pageEditorState!.files}
|
||||||
|
onToggleSelection={pageEditorState!.onToggleSelection}
|
||||||
|
onReorder={pageEditorState!.onReorder}
|
||||||
|
switchingTo={switchingTo}
|
||||||
|
viewOptionStyle={viewOptionStyle}
|
||||||
|
fileColorMap={pageEditorState!.fileColorMap}
|
||||||
|
selectedCount={pageEditorState!.selectedCount}
|
||||||
|
totalCount={pageEditorState!.totalCount}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div style={viewOptionStyle}>
|
<div style={viewOptionStyle}>
|
||||||
{currentView === "pageEditor" ? (
|
{switchingTo === "pageEditor" ? (
|
||||||
<>
|
<Loader size="sm" />
|
||||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
|
||||||
<span>Page Editor</span>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
|
||||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
|
||||||
<span>Page Editor</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -83,17 +94,7 @@ const createViewOptions = (
|
|||||||
const fileEditorOption = {
|
const fileEditorOption = {
|
||||||
label: (
|
label: (
|
||||||
<div style={viewOptionStyle}>
|
<div style={viewOptionStyle}>
|
||||||
{currentView === "fileEditor" ? (
|
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
|
||||||
<>
|
|
||||||
{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>
|
</div>
|
||||||
),
|
),
|
||||||
value: "fileEditor",
|
value: "fileEditor",
|
||||||
@ -111,9 +112,9 @@ const createViewOptions = (
|
|||||||
label: (
|
label: (
|
||||||
<div style={viewOptionStyle as React.CSSProperties}>
|
<div style={viewOptionStyle as React.CSSProperties}>
|
||||||
{switchingTo === view.workbenchId ? (
|
{switchingTo === view.workbenchId ? (
|
||||||
<Loader size="xs" />
|
<Loader size="sm" />
|
||||||
) : (
|
) : (
|
||||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
view.icon || <PictureAsPdfIcon fontSize="medium" />
|
||||||
)}
|
)}
|
||||||
<span>{view.label}</span>
|
<span>{view.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -144,6 +145,8 @@ const TopControls = ({
|
|||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||||
|
|
||||||
|
const pageEditorState = usePageEditorDropdownState();
|
||||||
|
|
||||||
const handleViewChange = useCallback((view: string) => {
|
const handleViewChange = useCallback((view: string) => {
|
||||||
if (!isValidWorkbench(view)) {
|
if (!isValidWorkbench(view)) {
|
||||||
return;
|
return;
|
||||||
@ -166,13 +169,25 @@ const TopControls = ({
|
|||||||
});
|
});
|
||||||
}, [setCurrentView]);
|
}, [setCurrentView]);
|
||||||
|
|
||||||
|
// Memoize view options to prevent SegmentedControl re-renders
|
||||||
|
const viewOptions = useMemo(() => createViewOptions(
|
||||||
|
currentView,
|
||||||
|
switchingTo,
|
||||||
|
activeFiles,
|
||||||
|
currentFileIndex,
|
||||||
|
onFileSelect,
|
||||||
|
pageEditorState,
|
||||||
|
customViews
|
||||||
|
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||||
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
|
<div className="flex justify-center">
|
||||||
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
data-tour="view-switcher"
|
data-tour="view-switcher"
|
||||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
data={viewOptions}
|
||||||
|
|
||||||
value={currentView}
|
value={currentView}
|
||||||
onChange={handleViewChange}
|
onChange={handleViewChange}
|
||||||
color="blue"
|
color="blue"
|
||||||
@ -185,18 +200,32 @@ const TopControls = ({
|
|||||||
}}
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
borderRadius: 9999,
|
borderRadius: '0 0 16px 16px',
|
||||||
maxHeight: '2.6rem',
|
height: '1.8rem',
|
||||||
|
backgroundColor: 'var(--bg-toolbar)',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderTop: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
outline: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
outlineOffset: '-1px',
|
||||||
|
padding: '0 0',
|
||||||
|
gap: '0',
|
||||||
},
|
},
|
||||||
control: {
|
control: {
|
||||||
borderRadius: 9999,
|
borderRadius: '0 0 16px 16px',
|
||||||
|
padding: '0',
|
||||||
|
border: 'none',
|
||||||
},
|
},
|
||||||
indicator: {
|
indicator: {
|
||||||
borderRadius: 9999,
|
borderRadius: '0 0 16px 16px',
|
||||||
maxHeight: '2rem',
|
height: '100%',
|
||||||
|
top: '0rem',
|
||||||
|
margin: '0',
|
||||||
|
border: 'none',
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
paddingTop: '0rem',
|
paddingTop: '0',
|
||||||
|
paddingBottom: '0',
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,154 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react';
|
||||||
|
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
|
|
||||||
|
import { FileId } from '@app/types/file';
|
||||||
|
|
||||||
|
interface UseFileItemDragDropParams {
|
||||||
|
fileId: FileId;
|
||||||
|
index: number;
|
||||||
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFileItemDragDropReturn {
|
||||||
|
itemRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
isDragging: boolean;
|
||||||
|
isDragOver: boolean;
|
||||||
|
dropPosition: 'above' | 'below';
|
||||||
|
movedRef: React.MutableRefObject<boolean>;
|
||||||
|
startRef: React.MutableRefObject<{ x: number; y: number } | null>;
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
|
onPointerMove: (e: React.PointerEvent) => void;
|
||||||
|
onPointerUp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle drag and drop functionality for file items in a list.
|
||||||
|
* Manages drag state, drop zones, and reordering logic using Pragmatic Drag and Drop.
|
||||||
|
*/
|
||||||
|
export const useFileItemDragDrop = ({
|
||||||
|
fileId,
|
||||||
|
index,
|
||||||
|
onReorder,
|
||||||
|
}: UseFileItemDragDropParams): UseFileItemDragDropReturn => {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
|
||||||
|
const itemRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Keep latest values without re-registering DnD
|
||||||
|
const indexRef = useRef(index);
|
||||||
|
const fileIdRef = useRef(fileId);
|
||||||
|
const dropPositionRef = useRef<'above' | 'below'>('below');
|
||||||
|
const onReorderRef = useRef(onReorder);
|
||||||
|
|
||||||
|
useEffect(() => { indexRef.current = index; }, [index]);
|
||||||
|
useEffect(() => { fileIdRef.current = fileId; }, [fileId]);
|
||||||
|
useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]);
|
||||||
|
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
|
||||||
|
|
||||||
|
// Gesture guard for row click vs drag
|
||||||
|
const movedRef = useRef(false);
|
||||||
|
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
startRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
movedRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (!startRef.current) return;
|
||||||
|
const dx = e.clientX - startRef.current.x;
|
||||||
|
const dy = e.clientY - startRef.current.y;
|
||||||
|
if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
startRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = itemRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const dragCleanup = draggable({
|
||||||
|
element,
|
||||||
|
getInitialData: () => ({
|
||||||
|
type: 'file-item',
|
||||||
|
fileId: fileIdRef.current,
|
||||||
|
fromIndex: indexRef.current,
|
||||||
|
}),
|
||||||
|
onDragStart: () => setIsDragging((p) => (p ? p : true)),
|
||||||
|
onDrop: () => setIsDragging((p) => (p ? false : p)),
|
||||||
|
canDrag: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropCleanup = dropTargetForElements({
|
||||||
|
element,
|
||||||
|
getData: () => ({
|
||||||
|
type: 'file-item',
|
||||||
|
fileId: fileIdRef.current,
|
||||||
|
toIndex: indexRef.current,
|
||||||
|
}),
|
||||||
|
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
|
||||||
|
onDragLeave: () => {
|
||||||
|
setIsDragOver((p) => (p ? false : p));
|
||||||
|
setDropPosition('below');
|
||||||
|
},
|
||||||
|
onDrag: ({ source }) => {
|
||||||
|
// Determine drop position based on cursor location
|
||||||
|
const element = itemRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
|
||||||
|
const midpoint = rect.top + rect.height / 2;
|
||||||
|
|
||||||
|
setDropPosition(clientY < midpoint ? 'below' : 'above');
|
||||||
|
},
|
||||||
|
onDrop: ({ source }) => {
|
||||||
|
setIsDragOver(false);
|
||||||
|
const dropPos = dropPositionRef.current;
|
||||||
|
setDropPosition('below');
|
||||||
|
const sourceData = source.data as any;
|
||||||
|
if (sourceData?.type === 'file-item') {
|
||||||
|
const fromIndex = sourceData.fromIndex as number;
|
||||||
|
let toIndex = indexRef.current;
|
||||||
|
|
||||||
|
// Adjust toIndex based on drop position
|
||||||
|
if (dropPos === 'below' && fromIndex < toIndex) {
|
||||||
|
// Dragging down, drop after target - no adjustment needed
|
||||||
|
} else if (dropPos === 'above' && fromIndex > toIndex) {
|
||||||
|
// Dragging up, drop before target - no adjustment needed
|
||||||
|
} else if (dropPos === 'below' && fromIndex > toIndex) {
|
||||||
|
// Dragging up but want below target
|
||||||
|
toIndex = toIndex + 1;
|
||||||
|
} else if (dropPos === 'above' && fromIndex < toIndex) {
|
||||||
|
// Dragging down but want above target
|
||||||
|
toIndex = toIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromIndex !== toIndex) {
|
||||||
|
onReorderRef.current(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try { dragCleanup(); } catch { /* cleanup */ }
|
||||||
|
try { dropCleanup(); } catch { /* cleanup */ }
|
||||||
|
};
|
||||||
|
}, []); // Stable - no dependencies
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemRef,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
dropPosition,
|
||||||
|
movedRef,
|
||||||
|
startRef,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -23,6 +23,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
padding-top: 3rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
||||||
|
|||||||
@ -174,5 +174,3 @@ export default function OverlayPdfsSettings({ parameters, onParameterChange, dis
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
|||||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||||
import { isStirlingFile } from '@app/types/fileContext';
|
import { isStirlingFile } from '@app/types/fileContext';
|
||||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||||
|
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||||
|
|
||||||
export interface EmbedPdfViewerProps {
|
export interface EmbedPdfViewerProps {
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
@ -122,39 +123,11 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
}, [previewFile, fileWithUrl]);
|
}, [previewFile, fileWithUrl]);
|
||||||
|
|
||||||
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
|
useWheelZoom({
|
||||||
useEffect(() => {
|
ref: viewerRef,
|
||||||
let accumulator = 0;
|
onZoomIn: zoomActions.zoomIn,
|
||||||
|
onZoomOut: zoomActions.zoomOut,
|
||||||
const handleWheel = (event: WheelEvent) => {
|
});
|
||||||
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
accumulator += event.deltaY;
|
|
||||||
const threshold = 10;
|
|
||||||
|
|
||||||
if (accumulator <= -threshold) {
|
|
||||||
// Accumulated scroll up - zoom in
|
|
||||||
zoomActions.zoomIn();
|
|
||||||
accumulator = 0;
|
|
||||||
} else if (accumulator >= threshold) {
|
|
||||||
// Accumulated scroll down - zoom out
|
|
||||||
zoomActions.zoomOut();
|
|
||||||
accumulator = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewerElement = viewerRef.current;
|
|
||||||
if (viewerElement) {
|
|
||||||
viewerElement.addEventListener('wheel', handleWheel, { passive: false });
|
|
||||||
return () => {
|
|
||||||
viewerElement.removeEventListener('wheel', handleWheel);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [zoomActions]);
|
|
||||||
|
|
||||||
// Handle keyboard zoom shortcuts
|
// Handle keyboard zoom shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
359
frontend/src/core/contexts/PageEditorContext.tsx
Normal file
359
frontend/src/core/contexts/PageEditorContext.tsx
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { FileId } from '@app/types/file';
|
||||||
|
import { useFileActions, useFileState } from '@app/contexts/FileContext';
|
||||||
|
import { PDFPage } from '@app/types/pageEditor';
|
||||||
|
import { MAX_PAGE_EDITOR_FILES } from '@app/components/pageEditor/fileColors';
|
||||||
|
|
||||||
|
// PageEditorFile is now defined locally in consuming components
|
||||||
|
// Components should derive file list directly from FileContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes file order based on the position of each file's first page
|
||||||
|
* @param pages - Current page order
|
||||||
|
* @returns Array of FileIds in order based on first page positions
|
||||||
|
*/
|
||||||
|
function computeFileOrderFromPages(pages: PDFPage[]): FileId[] {
|
||||||
|
// Find the first page for each file
|
||||||
|
const fileFirstPagePositions = new Map<FileId, number>();
|
||||||
|
|
||||||
|
pages.forEach((page, index) => {
|
||||||
|
const fileId = page.originalFileId;
|
||||||
|
if (!fileId) return;
|
||||||
|
|
||||||
|
if (!fileFirstPagePositions.has(fileId)) {
|
||||||
|
fileFirstPagePositions.set(fileId, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort files by their first page position
|
||||||
|
const fileOrder = Array.from(fileFirstPagePositions.entries())
|
||||||
|
.sort((a, b) => a[1] - b[1])
|
||||||
|
.map(entry => entry[0]);
|
||||||
|
|
||||||
|
return fileOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorders pages based on file reordering while preserving interlacing and manual page order
|
||||||
|
* @param currentPages - Current page order (may include manual reordering and interlacing)
|
||||||
|
* @param fromIndex - Source file index in the file order
|
||||||
|
* @param toIndex - Target file index in the file order
|
||||||
|
* @param orderedFileIds - File IDs in their current order
|
||||||
|
* @returns Reordered pages with updated page numbers
|
||||||
|
*/
|
||||||
|
function reorderPagesForFileMove(
|
||||||
|
currentPages: PDFPage[],
|
||||||
|
fromIndex: number,
|
||||||
|
toIndex: number,
|
||||||
|
orderedFileIds: FileId[]
|
||||||
|
): PDFPage[] {
|
||||||
|
// Get the file ID being moved
|
||||||
|
const movedFileId = orderedFileIds[fromIndex];
|
||||||
|
const targetFileId = orderedFileIds[toIndex];
|
||||||
|
|
||||||
|
// Extract pages belonging to the moved file (maintaining their relative order)
|
||||||
|
const movedFilePages: PDFPage[] = [];
|
||||||
|
const remainingPages: PDFPage[] = [];
|
||||||
|
|
||||||
|
currentPages.forEach(page => {
|
||||||
|
if (page.originalFileId === movedFileId) {
|
||||||
|
movedFilePages.push(page);
|
||||||
|
} else {
|
||||||
|
remainingPages.push(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the insertion point based on the target file
|
||||||
|
let insertionIndex = 0;
|
||||||
|
|
||||||
|
if (fromIndex < toIndex) {
|
||||||
|
// Moving down: insert AFTER the last page of ANY file that should come before us
|
||||||
|
// We need to find the last page belonging to any file at index <= toIndex in orderedFileIds
|
||||||
|
const filesBeforeUs = new Set(orderedFileIds.slice(0, toIndex + 1));
|
||||||
|
for (let i = remainingPages.length - 1; i >= 0; i--) {
|
||||||
|
const pageFileId = remainingPages[i].originalFileId;
|
||||||
|
if (pageFileId && filesBeforeUs.has(pageFileId)) {
|
||||||
|
insertionIndex = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Moving up: insert BEFORE the first page of target file
|
||||||
|
for (let i = 0; i < remainingPages.length; i++) {
|
||||||
|
if (remainingPages[i].originalFileId === targetFileId) {
|
||||||
|
insertionIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert moved pages at the calculated position
|
||||||
|
const reorderedPages = [
|
||||||
|
...remainingPages.slice(0, insertionIndex),
|
||||||
|
...movedFilePages,
|
||||||
|
...remainingPages.slice(insertionIndex)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Renumber all pages sequentially (clone to avoid mutation)
|
||||||
|
return reorderedPages.map((page, index) => ({
|
||||||
|
...page,
|
||||||
|
pageNumber: index + 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageEditorContextValue {
|
||||||
|
// Current page order (updated by PageEditor, used for file reordering)
|
||||||
|
currentPages: PDFPage[] | null;
|
||||||
|
updateCurrentPages: (pages: PDFPage[] | null) => void;
|
||||||
|
|
||||||
|
// Reordered pages (when file reordering happens)
|
||||||
|
reorderedPages: PDFPage[] | null;
|
||||||
|
clearReorderedPages: () => void;
|
||||||
|
|
||||||
|
// Page editor's own file order (independent of FileContext global order)
|
||||||
|
fileOrder: FileId[];
|
||||||
|
setFileOrder: (order: FileId[]) => void;
|
||||||
|
|
||||||
|
// Set file selection (calls FileContext actions)
|
||||||
|
setFileSelection: (fileId: FileId, selected: boolean) => void;
|
||||||
|
|
||||||
|
// Toggle file selection (calls FileContext actions)
|
||||||
|
toggleFileSelection: (fileId: FileId) => void;
|
||||||
|
|
||||||
|
// Select/deselect all files (calls FileContext actions)
|
||||||
|
selectAll: () => void;
|
||||||
|
deselectAll: () => void;
|
||||||
|
|
||||||
|
// Reorder files (only affects page editor's local order)
|
||||||
|
reorderFiles: (fromIndex: number, toIndex: number) => void;
|
||||||
|
|
||||||
|
// Update file order based on page positions (when pages are manually reordered)
|
||||||
|
updateFileOrderFromPages: (pages: PDFPage[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
interface PageEditorProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageEditorProvider({ children }: PageEditorProviderProps) {
|
||||||
|
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
|
||||||
|
const [reorderedPages, setReorderedPages] = useState<PDFPage[] | null>(null);
|
||||||
|
|
||||||
|
// Page editor's own file order (independent of FileContext)
|
||||||
|
const [fileOrder, setFileOrder] = useState<FileId[]>([]);
|
||||||
|
|
||||||
|
// Read from FileContext (for file metadata only, not order)
|
||||||
|
const { actions: fileActions } = useFileActions();
|
||||||
|
const { state } = useFileState();
|
||||||
|
|
||||||
|
// Keep a ref to always read latest state in stable callbacks
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// Track the previous FileContext order to detect actual changes
|
||||||
|
const prevFileContextIdsRef = useRef<FileId[]>([]);
|
||||||
|
|
||||||
|
// Initialize fileOrder from FileContext when files change (add/remove only)
|
||||||
|
useEffect(() => {
|
||||||
|
const currentFileIds = state.files.ids;
|
||||||
|
const prevFileIds = prevFileContextIdsRef.current;
|
||||||
|
|
||||||
|
// Only react to FileContext changes, not our own fileOrder changes
|
||||||
|
const fileContextChanged =
|
||||||
|
currentFileIds.length !== prevFileIds.length ||
|
||||||
|
!currentFileIds.every((id, idx) => id === prevFileIds[idx]);
|
||||||
|
|
||||||
|
if (!fileContextChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevFileContextIdsRef.current = currentFileIds;
|
||||||
|
|
||||||
|
// Collect new file IDs outside the setState callback so we can clear them after
|
||||||
|
let newFileIdsToProcess: FileId[] = [];
|
||||||
|
|
||||||
|
// Use functional setState to read latest fileOrder without depending on it
|
||||||
|
setFileOrder(currentOrder => {
|
||||||
|
// Identify new files
|
||||||
|
const newFileIds = currentFileIds.filter(id => !currentOrder.includes(id));
|
||||||
|
newFileIdsToProcess = newFileIds; // Store for cleanup
|
||||||
|
|
||||||
|
// Remove deleted files
|
||||||
|
const validFileOrder = currentOrder.filter(id => currentFileIds.includes(id));
|
||||||
|
|
||||||
|
if (newFileIds.length === 0 && validFileOrder.length === currentOrder.length) {
|
||||||
|
return currentOrder; // No changes needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always append new files to end
|
||||||
|
// If files have insertAfterPageId, page-level insertion is handled by usePageDocument
|
||||||
|
return [...validFileOrder, ...newFileIds];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear insertAfterPageId after a delay to allow usePageDocument to consume it first
|
||||||
|
setTimeout(() => {
|
||||||
|
newFileIdsToProcess.forEach(fileId => {
|
||||||
|
const stub = state.files.byId[fileId];
|
||||||
|
if (stub?.insertAfterPageId) {
|
||||||
|
fileActions.updateStirlingFileStub(fileId, { insertAfterPageId: undefined });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}, [state.files.ids, state.files.byId, fileActions]);
|
||||||
|
|
||||||
|
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
|
||||||
|
setCurrentPages(pages);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearReorderedPages = useCallback(() => {
|
||||||
|
setReorderedPages(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFileSelection = useCallback((fileId: FileId, selected: boolean) => {
|
||||||
|
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||||
|
const isAlreadySelected = currentSelection.includes(fileId);
|
||||||
|
|
||||||
|
// Check if we're trying to select when at limit
|
||||||
|
if (selected && !isAlreadySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
|
||||||
|
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update FileContext selection
|
||||||
|
const newSelectedIds = selected
|
||||||
|
? [...currentSelection, fileId]
|
||||||
|
: currentSelection.filter(id => id !== fileId);
|
||||||
|
|
||||||
|
fileActions.setSelectedFiles(newSelectedIds);
|
||||||
|
}, [fileActions]);
|
||||||
|
|
||||||
|
const toggleFileSelection = useCallback((fileId: FileId) => {
|
||||||
|
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||||
|
const isCurrentlySelected = currentSelection.includes(fileId);
|
||||||
|
|
||||||
|
// If toggling on and at limit, don't allow
|
||||||
|
if (!isCurrentlySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
|
||||||
|
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update FileContext selection
|
||||||
|
const newSelectedIds = isCurrentlySelected
|
||||||
|
? currentSelection.filter(id => id !== fileId)
|
||||||
|
: [...currentSelection, fileId];
|
||||||
|
|
||||||
|
fileActions.setSelectedFiles(newSelectedIds);
|
||||||
|
}, [fileActions]);
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
const allFileIds = stateRef.current.files.ids;
|
||||||
|
|
||||||
|
if (allFileIds.length > MAX_PAGE_EDITOR_FILES) {
|
||||||
|
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`);
|
||||||
|
fileActions.setSelectedFiles(allFileIds.slice(0, MAX_PAGE_EDITOR_FILES));
|
||||||
|
} else {
|
||||||
|
fileActions.setSelectedFiles(allFileIds);
|
||||||
|
}
|
||||||
|
}, [fileActions]);
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
fileActions.setSelectedFiles([]);
|
||||||
|
}, [fileActions]);
|
||||||
|
|
||||||
|
const reorderFiles = useCallback((fromIndex: number, toIndex: number) => {
|
||||||
|
// Reorder local fileOrder array (page editor workspace only)
|
||||||
|
const newOrder = [...fileOrder];
|
||||||
|
const [movedFileId] = newOrder.splice(fromIndex, 1);
|
||||||
|
newOrder.splice(toIndex, 0, movedFileId);
|
||||||
|
setFileOrder(newOrder);
|
||||||
|
|
||||||
|
// If current pages available, reorder them based on file move
|
||||||
|
if (currentPages && currentPages.length > 0 && fromIndex !== toIndex) {
|
||||||
|
// Get the current file order from pages (files that have pages loaded)
|
||||||
|
const currentFileOrder: FileId[] = [];
|
||||||
|
const filesSeen = new Set<FileId>();
|
||||||
|
currentPages.forEach(page => {
|
||||||
|
const fileId = page.originalFileId;
|
||||||
|
if (fileId && !filesSeen.has(fileId)) {
|
||||||
|
filesSeen.add(fileId);
|
||||||
|
currentFileOrder.push(fileId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the target file ID from the NEW order (after the move)
|
||||||
|
// When moving down: we want to position after the file at toIndex-1 (file just before insertion)
|
||||||
|
// When moving up: we want to position before the file at toIndex+1 (file just after insertion)
|
||||||
|
const targetFileId = fromIndex < toIndex
|
||||||
|
? newOrder[toIndex - 1] // Moving down: target is the file just before where we inserted
|
||||||
|
: newOrder[toIndex + 1]; // Moving up: target is the file just after where we inserted
|
||||||
|
|
||||||
|
// Find their positions in the current page order (not the full file list)
|
||||||
|
const pageOrderFromIndex = currentFileOrder.findIndex(id => id === movedFileId);
|
||||||
|
const pageOrderToIndex = currentFileOrder.findIndex(id => id === targetFileId);
|
||||||
|
|
||||||
|
// Only reorder pages if both files have pages loaded
|
||||||
|
if (pageOrderFromIndex >= 0 && pageOrderToIndex >= 0) {
|
||||||
|
const reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder);
|
||||||
|
setReorderedPages(reorderedPagesResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fileOrder, currentPages]);
|
||||||
|
|
||||||
|
const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => {
|
||||||
|
if (!pages || pages.length === 0) return;
|
||||||
|
|
||||||
|
// Compute the new file order based on page positions
|
||||||
|
const newFileOrder = computeFileOrderFromPages(pages);
|
||||||
|
|
||||||
|
if (newFileOrder.length > 0) {
|
||||||
|
// Update local page editor file order (not FileContext)
|
||||||
|
setFileOrder(newFileOrder);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const value: PageEditorContextValue = useMemo(() => ({
|
||||||
|
currentPages,
|
||||||
|
updateCurrentPages,
|
||||||
|
reorderedPages,
|
||||||
|
clearReorderedPages,
|
||||||
|
fileOrder,
|
||||||
|
setFileOrder,
|
||||||
|
setFileSelection,
|
||||||
|
toggleFileSelection,
|
||||||
|
selectAll,
|
||||||
|
deselectAll,
|
||||||
|
reorderFiles,
|
||||||
|
updateFileOrderFromPages,
|
||||||
|
}), [
|
||||||
|
currentPages,
|
||||||
|
updateCurrentPages,
|
||||||
|
reorderedPages,
|
||||||
|
clearReorderedPages,
|
||||||
|
fileOrder,
|
||||||
|
setFileSelection,
|
||||||
|
toggleFileSelection,
|
||||||
|
selectAll,
|
||||||
|
deselectAll,
|
||||||
|
reorderFiles,
|
||||||
|
updateFileOrderFromPages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageEditorContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PageEditorContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageEditor() {
|
||||||
|
const context = useContext(PageEditorContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePageEditor must be used within PageEditorProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@ -149,8 +149,10 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
|
|
||||||
// Validate that all IDs exist in current state
|
// Validate that all IDs exist in current state
|
||||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||||
|
|
||||||
// Reorder selected files by passed order
|
// Reorder selected files by passed order
|
||||||
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: {
|
files: {
|
||||||
|
|||||||
83
frontend/src/core/hooks/useWheelZoom.ts
Normal file
83
frontend/src/core/hooks/useWheelZoom.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { RefObject, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface UseWheelZoomOptions {
|
||||||
|
/**
|
||||||
|
* Element the wheel listener should be bound to.
|
||||||
|
*/
|
||||||
|
ref: RefObject<Element | null>;
|
||||||
|
/**
|
||||||
|
* Callback executed when the hook decides to zoom in.
|
||||||
|
*/
|
||||||
|
onZoomIn: () => void;
|
||||||
|
/**
|
||||||
|
* Callback executed when the hook decides to zoom out.
|
||||||
|
*/
|
||||||
|
onZoomOut: () => void;
|
||||||
|
/**
|
||||||
|
* Whether the wheel listener should be active.
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
/**
|
||||||
|
* How much delta needs to accumulate before a zoom action is triggered.
|
||||||
|
* Defaults to 10 which matches the previous implementations.
|
||||||
|
*/
|
||||||
|
threshold?: number;
|
||||||
|
/**
|
||||||
|
* Whether a Ctrl/Cmd modifier is required for zooming. Defaults to true so
|
||||||
|
* we only react to pinch gestures and intentional ctrl+wheel zooming.
|
||||||
|
*/
|
||||||
|
requireModifierKey?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared hook for handling wheel-based zoom across components.
|
||||||
|
* It normalises accumulated delta behaviour, prevents default scrolling when
|
||||||
|
* zoom is triggered, and keeps the handler detached when disabled.
|
||||||
|
*/
|
||||||
|
export function useWheelZoom({
|
||||||
|
ref,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
enabled = true,
|
||||||
|
threshold = 10,
|
||||||
|
requireModifierKey = true,
|
||||||
|
}: UseWheelZoomOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accumulator = 0;
|
||||||
|
|
||||||
|
const handleWheel = (event: Event) => {
|
||||||
|
const wheelEvent = event as WheelEvent;
|
||||||
|
const hasModifier = wheelEvent.ctrlKey || wheelEvent.metaKey;
|
||||||
|
if (requireModifierKey && !hasModifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wheelEvent.preventDefault();
|
||||||
|
wheelEvent.stopPropagation();
|
||||||
|
|
||||||
|
accumulator += wheelEvent.deltaY;
|
||||||
|
|
||||||
|
if (accumulator <= -threshold) {
|
||||||
|
onZoomIn();
|
||||||
|
accumulator = 0;
|
||||||
|
} else if (accumulator >= threshold) {
|
||||||
|
onZoomOut();
|
||||||
|
accumulator = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('wheel', handleWheel);
|
||||||
|
};
|
||||||
|
}, [ref, onZoomIn, onZoomOut, enabled, threshold, requireModifierKey]);
|
||||||
|
}
|
||||||
@ -10,6 +10,11 @@ export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
|
|||||||
|
|
||||||
export const Z_INDEX_AUTOMATE_MODAL = 1100;
|
export const Z_INDEX_AUTOMATE_MODAL = 1100;
|
||||||
|
|
||||||
|
// page editor Zindexes
|
||||||
|
export const Z_INDEX_HOVER_ACTION_MENU = 100;
|
||||||
|
export const Z_INDEX_SELECTION_BOX = 1000;
|
||||||
|
export const Z_INDEX_DROP_INDICATOR = 1001;
|
||||||
|
export const Z_INDEX_DRAG_BADGE = 1001;
|
||||||
// Modal that appears on top of config modal (e.g., restart confirmation)
|
// Modal that appears on top of config modal (e.g., restart confirmation)
|
||||||
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
||||||
|
|
||||||
@ -17,4 +22,3 @@ export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
|||||||
export const Z_INDEX_TOAST = 10001;
|
export const Z_INDEX_TOAST = 10001;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -50,8 +50,7 @@ const Split = (props: BaseToolProps) => {
|
|||||||
{
|
{
|
||||||
title: t("split.steps.chooseMethod", "Choose Method"),
|
title: t("split.steps.chooseMethod", "Choose Method"),
|
||||||
isCollapsed: !!base.params.parameters.method, // Collapse when method is selected
|
isCollapsed: !!base.params.parameters.method, // Collapse when method is selected
|
||||||
onCollapsedClick: () => base.params.updateParameter('method', '')
|
onCollapsedClick: () => base.params.updateParameter('method', ''),
|
||||||
,
|
|
||||||
tooltip: methodTips,
|
tooltip: methodTips,
|
||||||
content: (
|
content: (
|
||||||
<CardSelector<SplitMethod, MethodOption>
|
<CardSelector<SplitMethod, MethodOption>
|
||||||
@ -86,7 +85,7 @@ const Split = (props: BaseToolProps) => {
|
|||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: "Split Results",
|
title: t("split.resultsTitle", "Split Results"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
onUndo: base.handleUndo,
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -89,9 +89,18 @@ export function isStirlingFile(file: File): file is StirlingFile {
|
|||||||
|
|
||||||
// Create a StirlingFile from a regular File object
|
// Create a StirlingFile from a regular File object
|
||||||
export function createStirlingFile(file: File, id?: FileId): StirlingFile {
|
export function createStirlingFile(file: File, id?: FileId): StirlingFile {
|
||||||
// Check if file is already a StirlingFile to avoid property redefinition
|
// If the file already has Stirling metadata and we aren't trying to override it,
|
||||||
|
// return as–is. When a new id is requested we clone the File so we can embed
|
||||||
|
// the fresh identifier without mutating the original object.
|
||||||
if (isStirlingFile(file)) {
|
if (isStirlingFile(file)) {
|
||||||
return file; // Already has fileId and quickKey properties
|
if (!id || file.fileId === id) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
file = new File([file], file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileId = id || createFileId();
|
const fileId = id || createFileId();
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { FileId } from '@app/types/file';
|
import { FileId } from '@app/types/file';
|
||||||
|
|
||||||
|
export type PageSize = 'A4' | 'Letter' | 'Legal' | 'A3' | 'A5';
|
||||||
|
export type PageOrientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
export interface PageBreakSettings {
|
||||||
|
size: PageSize;
|
||||||
|
orientation: PageOrientation;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PDFPage {
|
export interface PDFPage {
|
||||||
id: string;
|
id: string;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
@ -9,7 +17,9 @@ export interface PDFPage {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
splitAfter?: boolean;
|
splitAfter?: boolean;
|
||||||
isBlankPage?: boolean;
|
isBlankPage?: boolean;
|
||||||
|
isPlaceholder?: boolean;
|
||||||
originalFileId?: FileId;
|
originalFileId?: FileId;
|
||||||
|
pageBreakSettings?: PageBreakSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PDFDocument {
|
export interface PDFDocument {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user