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:
Reece Browne 2025-11-13 12:53:57 +00:00 committed by GitHub
parent d06391a927
commit aa20dbb7a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3833 additions and 990 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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);
}

View File

@ -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}

View File

@ -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;
}

View File

@ -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>
);
};

View File

@ -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

View File

@ -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"
>

View File

@ -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,

View File

@ -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 = [];

View 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})`);
}

View File

@ -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
>;

View File

@ -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
>;

View File

@ -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]);
}

View File

@ -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;
}

View File

@ -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(() => {

View File

@ -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]);
}

View File

@ -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>;

View File

@ -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

View File

@ -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
>;

View File

@ -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>;

View File

@ -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,

View File

@ -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(

View File

@ -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;

View File

@ -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>

View 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>
);
};

View File

@ -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;
}

View File

@ -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',
}
}}
/>

View File

@ -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,
};
};

View File

@ -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) */

View File

@ -174,5 +174,3 @@ export default function OverlayPdfsSettings({ parameters, onParameterChange, dis
</Stack>
);
}

View File

@ -131,4 +131,4 @@ export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent |
};
return tooltipMap[method];
};
};

View File

@ -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(() => {

View 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;
}

View File

@ -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: {

View 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]);
}

View File

@ -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;

View File

@ -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,
},

View File

@ -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 asis. 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();

View File

@ -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 {