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",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
@ -505,6 +506,63 @@
|
||||
"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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
|
||||
@ -1011,7 +1011,8 @@
|
||||
"title": "Choose Your Split Method"
|
||||
}
|
||||
},
|
||||
"selectMethod": "Select a split method"
|
||||
"selectMethod": "Select a split method",
|
||||
"resultsTitle": "Split Results"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Rotate PDF",
|
||||
@ -3605,7 +3606,8 @@
|
||||
"toggleAnnotations": "Toggle Annotations Visibility",
|
||||
"annotationMode": "Toggle Annotation Mode",
|
||||
"draw": "Draw",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"saveChanges": "Save Changes"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search PDF",
|
||||
|
||||
@ -15,6 +15,7 @@ import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
@ -64,15 +65,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
.workbench-scrollable {
|
||||
.workbenchScrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar {
|
||||
.workbenchScrollable::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-track {
|
||||
.workbenchScrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb {
|
||||
.workbenchScrollable::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
@ -187,18 +186,16 @@ export default function Workbench() {
|
||||
|
||||
{/* Main content area */}
|
||||
<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={{
|
||||
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()}
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics ?? undefined}
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
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 { useVirtualizer } from '@tanstack/react-virtual';
|
||||
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 {
|
||||
id: string;
|
||||
splitAfter?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
originalFileId?: string;
|
||||
pageNumber?: number;
|
||||
}
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: string[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
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;
|
||||
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>({
|
||||
items,
|
||||
renderItem,
|
||||
onReorderPages,
|
||||
getThumbnailData,
|
||||
zoomLevel = 1.0,
|
||||
selectedFileIds,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
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
|
||||
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
|
||||
const calculateItemsPerRow = useCallback(() => {
|
||||
@ -38,8 +374,8 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
|
||||
// Convert rem to pixels for calculation
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||
|
||||
// Calculate how many items fit: (width - gap) / (itemWidth + 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);
|
||||
|
||||
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(() => {
|
||||
const updateLayout = () => {
|
||||
const newItemsPerRow = calculateItemsPerRow();
|
||||
@ -72,86 +408,410 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
window.removeEventListener('resize', updateLayout);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateItemsPerRow]);
|
||||
}, [calculateItemsPerRow, zoomLevel]);
|
||||
|
||||
// Virtualization with react-virtual library
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: Math.ceil(items.length / itemsPerRow),
|
||||
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
||||
count: Math.ceil(visibleItems.length / itemsPerRow),
|
||||
getScrollElement,
|
||||
estimateSize: () => {
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
|
||||
},
|
||||
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
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
margin: '0 auto',
|
||||
maxWidth: `${gridWidth}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||
const rowItems = items.slice(startIndex, endIndex);
|
||||
// Calculate selection box dimensions
|
||||
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
|
||||
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
|
||||
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
|
||||
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
|
||||
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
|
||||
zIndex: Z_INDEX_SELECTION_BOX,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
// Calculate drop indicator position
|
||||
const dropIndicatorStyle = useMemo(() => {
|
||||
if (!hoveredItemId || !dropSide || !activeId) return null;
|
||||
|
||||
const element = itemRefs.current.get(hoveredItemId);
|
||||
const container = containerRef.current;
|
||||
if (!element || !container) return null;
|
||||
|
||||
const itemRect = element.getBoundingClientRect();
|
||||
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
|
||||
key={virtualRow.index}
|
||||
className={styles.virtualRow}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: GRID_CONSTANTS.ITEM_GAP,
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Item */}
|
||||
{renderItem(item, actualIndex, itemRefs)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className={styles.rowContent}
|
||||
style={{
|
||||
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@ -30,28 +50,6 @@
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.pageMoving {
|
||||
z-index: 10;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Multi-page drag indicator */
|
||||
.multiDragIndicator {
|
||||
position: fixed;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -138,12 +138,12 @@ const PageEditorControls = ({
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<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 />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<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 />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@ -156,7 +156,7 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@ -168,7 +168,7 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@ -180,7 +180,7 @@ const PageEditorControls = ({
|
||||
onClick={onDelete}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@ -192,7 +192,7 @@ const PageEditorControls = ({
|
||||
onClick={onSplit}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@ -204,7 +204,7 @@ const PageEditorControls = ({
|
||||
onClick={onPageBreak}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
|
||||
@ -8,12 +8,13 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
|
||||
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
|
||||
|
||||
@ -22,11 +23,17 @@ interface PageThumbnailProps {
|
||||
index: number;
|
||||
totalPages: number;
|
||||
originalFile?: File;
|
||||
fileColorIndex: number;
|
||||
selectedPageIds: string[];
|
||||
selectionMode: boolean;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
isBoxSelected?: boolean;
|
||||
clearBoxSelection?: () => void;
|
||||
activeDragIds: string[];
|
||||
justMoved?: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
dragHandleProps?: any;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: () => void;
|
||||
@ -40,19 +47,25 @@ interface PageThumbnailProps {
|
||||
pdfDocument: PDFDocument;
|
||||
setPdfDocument: (doc: PDFDocument) => void;
|
||||
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> = ({
|
||||
page,
|
||||
index,
|
||||
index: _index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
fileColorIndex,
|
||||
selectedPageIds,
|
||||
selectionMode,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
isBoxSelected = false,
|
||||
clearBoxSelection,
|
||||
activeDragIds,
|
||||
pageRefs,
|
||||
dragHandleProps,
|
||||
onReorderPages,
|
||||
onTogglePage,
|
||||
onExecuteCommand,
|
||||
@ -64,17 +77,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
pdfDocument,
|
||||
splitPositions,
|
||||
onInsertFiles,
|
||||
zoomLevel = 1.0,
|
||||
justMoved = false,
|
||||
}: PageThumbnailProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const pageIndex = page.pageNumber - 1;
|
||||
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const lastClickTimeRef = useRef<number>(0);
|
||||
|
||||
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();
|
||||
|
||||
// Check if this page is currently being dragged
|
||||
const isDragging = activeDragIds.includes(page.id);
|
||||
|
||||
// Calculate document aspect ratio from first non-blank page
|
||||
const getDocumentAspectRatio = useCallback(() => {
|
||||
// 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]);
|
||||
|
||||
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) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
pageNumber: page.pageNumber,
|
||||
pageId: page.id,
|
||||
selectedPageIds: [page.id]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (location.current.dropTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = location.current.dropTargets[0];
|
||||
const targetData = dropTarget.data;
|
||||
|
||||
if (targetData.type === 'page') {
|
||||
const targetPageNumber = targetData.pageNumber as number;
|
||||
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||
if (targetIndex !== -1) {
|
||||
onReorderPages(page.pageNumber, targetIndex, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
element.style.cursor = 'grab';
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: (_) => {}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
} else {
|
||||
pageRefs.current.delete(page.id);
|
||||
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||
(dragElementRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
|
||||
|
||||
// Call dnd-kit's ref if provided
|
||||
if (dragHandleProps?.ref) {
|
||||
dragHandleProps.ref(element);
|
||||
}
|
||||
}, [page.id, pageRefs, dragHandleProps]);
|
||||
|
||||
|
||||
// DOM command handlers
|
||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
||||
@ -216,13 +196,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
e.stopPropagation();
|
||||
|
||||
// Create a command to toggle split at this position
|
||||
const command = createSplitCommand(index);
|
||||
const command = createSplitCommand(pageIndex);
|
||||
onExecuteCommand(command);
|
||||
|
||||
const hasSplit = splitPositions.has(index);
|
||||
const hasSplit = splitPositions.has(pageIndex);
|
||||
const action = hasSplit ? 'removed' : 'added';
|
||||
onSetStatus(`Split marker ${action} after position ${index + 1}`);
|
||||
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
||||
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
||||
}, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
||||
|
||||
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@ -231,9 +211,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
// Open file manager modal with custom handler for page insertion
|
||||
openFilesModal({
|
||||
insertAfterPage: page.pageNumber,
|
||||
customHandler: (files: File[], insertAfterPage?: number) => {
|
||||
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
||||
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 distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// If mouse moved less than 5 pixels, consider it a click (not a drag)
|
||||
if (distance < 5 && !isDragging) {
|
||||
onTogglePage(page.id);
|
||||
// If mouse moved less than 2 pixels, consider it a click (not a drag)
|
||||
if (distance < 2 && !isDragging) {
|
||||
// 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);
|
||||
setMouseStartPos(null);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
@ -278,6 +272,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
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
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
@ -286,14 +285,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
label: 'Move Left',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
if (pageIndex > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
onReorderPages(page.pageNumber, pageIndex - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
},
|
||||
disabled: index === 0
|
||||
disabled: pageIndex === 0
|
||||
},
|
||||
{
|
||||
id: 'move-right',
|
||||
@ -301,14 +300,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
label: 'Move Right',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
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);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
},
|
||||
disabled: index === totalPages - 1
|
||||
disabled: pageIndex === totalPages - 1
|
||||
},
|
||||
{
|
||||
id: 'rotate-left',
|
||||
@ -334,7 +336,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Split After',
|
||||
onClick: handleSplit,
|
||||
hidden: index >= totalPages - 1,
|
||||
hidden: pageIndex >= totalPages - 1,
|
||||
},
|
||||
{
|
||||
id: 'insert',
|
||||
@ -342,11 +344,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
label: 'Insert File After',
|
||||
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 (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
ref={mergedRef}
|
||||
{...restDragProps}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
className={`
|
||||
@ -354,24 +357,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
!rounded-lg
|
||||
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[20rem]
|
||||
flex items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
|
||||
`}
|
||||
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}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@ -414,12 +418,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||
<div
|
||||
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@ -440,11 +445,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: 2
|
||||
}}></div>
|
||||
}} />
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<PrivateContent>
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
@ -476,7 +482,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
position: 'absolute',
|
||||
top: 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',
|
||||
borderRadius: 8,
|
||||
zIndex: 2,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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)
|
||||
export abstract class DOMCommand {
|
||||
@ -67,7 +67,7 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
private pagesToDelete: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private setSelectedPageIds: (pageIds: string[]) => void,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void,
|
||||
private getSelectedPages: () => number[],
|
||||
@ -99,6 +99,13 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
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)
|
||||
const remainingPages = currentDoc.pages.filter(page =>
|
||||
!this.pageIdsToDelete.includes(page.id)
|
||||
@ -106,7 +113,7 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
|
||||
if (remainingPages.length === 0) {
|
||||
// If all pages would be deleted, clear selection/splits and close PDF
|
||||
this.setSelectedPages([]);
|
||||
this.setSelectedPageIds([]);
|
||||
this.setSplitPositions(new Set());
|
||||
this.onAllPagesDeleted?.();
|
||||
return;
|
||||
@ -135,7 +142,12 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
|
||||
// Apply changes
|
||||
this.setDocument(updatedDocument);
|
||||
this.setSelectedPages([]);
|
||||
|
||||
const remainingSelectedPageIds = remainingPages
|
||||
.filter((page) => selectedIdSet.has(page.id))
|
||||
.map((page) => page.id);
|
||||
this.setSelectedPageIds(remainingSelectedPageIds);
|
||||
|
||||
this.setSplitPositions(newPositions);
|
||||
}
|
||||
|
||||
@ -145,7 +157,12 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
// Simply restore the complete original document state
|
||||
this.setDocument(this.originalDocument);
|
||||
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 {
|
||||
@ -161,7 +178,8 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
private targetIndex: number,
|
||||
private selectedPages: number[] | undefined,
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private onReorderComplete?: (newPages: PDFPage[]) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@ -196,7 +214,13 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
} else {
|
||||
// Single page reorder
|
||||
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) => {
|
||||
page.pageNumber = index + 1;
|
||||
@ -210,6 +234,11 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
};
|
||||
|
||||
this.setDocument(reorderedDocument);
|
||||
|
||||
// Notify that reordering is complete
|
||||
if (this.onReorderComplete) {
|
||||
this.onReorderComplete(newPages);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
@ -415,7 +446,8 @@ export class PageBreakCommand extends DOMCommand {
|
||||
constructor(
|
||||
private selectedPageNumbers: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private settings?: PageBreakSettings
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@ -450,7 +482,8 @@ export class PageBreakCommand extends DOMCommand {
|
||||
rotation: 0,
|
||||
selected: 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);
|
||||
this.insertedPages.push(blankPage);
|
||||
@ -883,6 +916,10 @@ export class UndoManager {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
hasHistory(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
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 { useFileState } from '@app/contexts/FileContext';
|
||||
import { usePageEditor } from '@app/contexts/PageEditorContext';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
@ -15,14 +16,40 @@ export interface PageDocumentHook {
|
||||
*/
|
||||
export function usePageDocument(): PageDocumentHook {
|
||||
const { state, selectors } = useFileState();
|
||||
const { fileOrder, currentPages } = usePageEditor();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
||||
// This ensures the page editor respects its own workspace ordering
|
||||
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();
|
||||
|
||||
// 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
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
@ -32,6 +59,10 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
// 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 => {
|
||||
if (activeFileIds.length === 0) return null;
|
||||
|
||||
@ -43,10 +74,14 @@ export function usePageDocument(): PageDocumentHook {
|
||||
return null;
|
||||
}
|
||||
|
||||
const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
||||
|
||||
const name =
|
||||
activeFileIds.length === 1
|
||||
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||
: activeFileIds
|
||||
namingFileIds.length <= 1
|
||||
? (namingFileIds[0]
|
||||
? selectors.getStirlingFileStub(namingFileIds[0])?.name ?? 'document.pdf'
|
||||
: 'document.pdf')
|
||||
: namingFileIds
|
||||
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.join(' + ');
|
||||
|
||||
@ -69,13 +104,28 @@ export function usePageDocument(): PageDocumentHook {
|
||||
// Build pages by interleaving original pages with insertions
|
||||
let pages: PDFPage[] = [];
|
||||
|
||||
// Helper function to create pages from a file
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||
// Helper function to create pages from a file (or placeholder if deselected)
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
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;
|
||||
let filePages: PDFPage[] = [];
|
||||
|
||||
@ -90,6 +140,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
splitAfter: page.splitAfter || false,
|
||||
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
} else if (processedFile?.totalPages) {
|
||||
// Fallback: create pages without thumbnails but with correct count
|
||||
@ -102,16 +153,30 @@ export function usePageDocument(): PageDocumentHook {
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
originalFileIds.forEach(fileId => {
|
||||
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
|
||||
sortedOriginalFileIds.forEach(fileId => {
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering
|
||||
originalFilePages.push(...filePages);
|
||||
});
|
||||
|
||||
@ -130,7 +195,8 @@ export function usePageDocument(): PageDocumentHook {
|
||||
// Collect all pages to insert
|
||||
const allNewPages: PDFPage[] = [];
|
||||
fileIds.forEach(fileId => {
|
||||
const insertedPages = createPagesFromFile(fileId, 1);
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const insertedPages = createPagesFromFile(fileId, 1, isSelected);
|
||||
allNewPages.push(...insertedPages);
|
||||
});
|
||||
|
||||
@ -147,6 +213,29 @@ export function usePageDocument(): PageDocumentHook {
|
||||
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 = {
|
||||
id: activeFileIds.join('-'),
|
||||
name,
|
||||
@ -156,7 +245,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
};
|
||||
|
||||
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
|
||||
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,26 +1,27 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export interface PageEditorState {
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPageIds: string[];
|
||||
|
||||
// Animation state
|
||||
|
||||
// Animation state
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
|
||||
|
||||
// Split state
|
||||
splitPositions: Set<number>;
|
||||
|
||||
|
||||
// Export state
|
||||
exportLoading: boolean;
|
||||
|
||||
|
||||
// Actions
|
||||
setSelectionMode: (mode: boolean) => void;
|
||||
setSelectedPageIds: (pages: string[]) => void;
|
||||
setMovingPage: (pageNumber: number | null) => void;
|
||||
setIsAnimating: (animating: boolean) => void;
|
||||
setSplitPositions: (positions: Set<number>) => void;
|
||||
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
setExportLoading: (loading: boolean) => void;
|
||||
|
||||
// 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;
|
||||
handleDelete: () => void;
|
||||
onExportSelected: () => void;
|
||||
onSaveChanges: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
||||
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
||||
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
||||
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
||||
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
visible: totalPages > 0,
|
||||
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',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
selectByNumberLabel,
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
saveChangesLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
|
||||
@ -68,8 +68,6 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
|
||||
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import styles from '@app/components/shared/HoverActionMenu.module.css';
|
||||
import { Z_INDEX_HOVER_ACTION_MENU } from '@app/styles/zIndex';
|
||||
|
||||
export interface HoverAction {
|
||||
id: string;
|
||||
@ -34,7 +35,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
return (
|
||||
<div
|
||||
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()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@ -44,10 +45,10 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
style={{ color: action.color || 'var(--right-rail-icon)' }}
|
||||
>
|
||||
{action.icon}
|
||||
</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
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
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 { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||
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 = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
paddingTop: '0.3rem',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'center',
|
||||
padding: '2px 1rem',
|
||||
};
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
// Helper function to create view options for SegmentedControl
|
||||
const createViewOptions = (
|
||||
currentView: WorkbenchType,
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
pageEditorState?: PageEditorDropdownState,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
// Viewer dropdown logic
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showDropdown = isInViewer && hasMultipleFiles;
|
||||
const showViewerDropdown = isInViewer && hasMultipleFiles;
|
||||
|
||||
const viewerOption = {
|
||||
label: showDropdown ? (
|
||||
label: showViewerDropdown ? (
|
||||
<FileDropdownMenu
|
||||
displayName={displayName}
|
||||
displayName={viewerDisplayName}
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
@ -51,29 +53,38 @@ const createViewOptions = (
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
<VisibilityIcon fontSize="medium" />
|
||||
)}
|
||||
<PrivateContent>{displayName}</PrivateContent>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
};
|
||||
|
||||
// Page Editor dropdown logic
|
||||
const isInPageEditor = currentView === 'pageEditor';
|
||||
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
|
||||
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
|
||||
|
||||
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}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
{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" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
@ -83,17 +94,7 @@ const createViewOptions = (
|
||||
const fileEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
)}
|
||||
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
|
||||
</div>
|
||||
),
|
||||
value: "fileEditor",
|
||||
@ -111,9 +112,9 @@ const createViewOptions = (
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
view.icon || <PictureAsPdfIcon fontSize="medium" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
@ -144,6 +145,8 @@ const TopControls = ({
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const pageEditorState = usePageEditorDropdownState();
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
@ -166,13 +169,25 @@ const TopControls = ({
|
||||
});
|
||||
}, [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 (
|
||||
<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
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
data={viewOptions}
|
||||
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
@ -185,18 +200,32 @@ const TopControls = ({
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2.6rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
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: {
|
||||
borderRadius: 9999,
|
||||
borderRadius: '0 0 16px 16px',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
},
|
||||
indicator: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
height: '100%',
|
||||
top: '0rem',
|
||||
margin: '0',
|
||||
border: 'none',
|
||||
},
|
||||
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;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
padding-top: 3rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -131,4 +131,4 @@ export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent |
|
||||
};
|
||||
|
||||
return tooltipMap[method];
|
||||
};
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
@ -122,39 +123,11 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [previewFile, fileWithUrl]);
|
||||
|
||||
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
|
||||
useEffect(() => {
|
||||
let accumulator = 0;
|
||||
|
||||
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]);
|
||||
useWheelZoom({
|
||||
ref: viewerRef,
|
||||
onZoomIn: zoomActions.zoomIn,
|
||||
onZoomOut: zoomActions.zoomOut,
|
||||
});
|
||||
|
||||
// Handle keyboard zoom shortcuts
|
||||
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
|
||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||
|
||||
// Reorder selected files by passed order
|
||||
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
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;
|
||||
|
||||
// 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)
|
||||
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;
|
||||
|
||||
|
||||
|
||||
|
||||
@ -50,8 +50,7 @@ const Split = (props: BaseToolProps) => {
|
||||
{
|
||||
title: t("split.steps.chooseMethod", "Choose Method"),
|
||||
isCollapsed: !!base.params.parameters.method, // Collapse when method is selected
|
||||
onCollapsedClick: () => base.params.updateParameter('method', '')
|
||||
,
|
||||
onCollapsedClick: () => base.params.updateParameter('method', ''),
|
||||
tooltip: methodTips,
|
||||
content: (
|
||||
<CardSelector<SplitMethod, MethodOption>
|
||||
@ -86,7 +85,7 @@ const Split = (props: BaseToolProps) => {
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: "Split Results",
|
||||
title: t("split.resultsTitle", "Split Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
|
||||
@ -89,9 +89,18 @@ export function isStirlingFile(file: File): file is StirlingFile {
|
||||
|
||||
// Create a StirlingFile from a regular File object
|
||||
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)) {
|
||||
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();
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
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 {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
@ -9,7 +17,9 @@ export interface PDFPage {
|
||||
selected: boolean;
|
||||
splitAfter?: boolean;
|
||||
isBlankPage?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
originalFileId?: FileId;
|
||||
pageBreakSettings?: PageBreakSettings;
|
||||
}
|
||||
|
||||
export interface PDFDocument {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user