mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
Fix and improve pageeditor
This commit is contained in:
parent
9b14609236
commit
646cedfb0f
@ -11,7 +11,8 @@
|
|||||||
"Bash(npm test:*)",
|
"Bash(npm test:*)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(node:*)"
|
"Bash(node:*)",
|
||||||
|
"Bash(npm run dev:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"defaultMode": "acceptEdits"
|
"defaultMode": "acceptEdits"
|
||||||
|
52
frontend/package-lock.json
generated
52
frontend/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
@ -17,6 +18,7 @@
|
|||||||
"@mui/icons-material": "^7.1.0",
|
"@mui/icons-material": "^7.1.0",
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.0",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
@ -119,6 +121,17 @@
|
|||||||
"is-potential-custom-element-name": "^1.0.1"
|
"is-potential-custom-element-name": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@atlaskit/pragmatic-drag-and-drop": {
|
||||||
|
"version": "1.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz",
|
||||||
|
"integrity": "sha512-lZHnO9BJdHPKnwB0uvVUCyDnIhL+WAHzXQ2EXX0qacogOsnvIUiCgY0BLKhBqTCWln3/f/Ox5jU54MKO6ayh9A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.0.0",
|
||||||
|
"bind-event-listener": "^3.0.0",
|
||||||
|
"raf-schd": "^4.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@ -2226,6 +2239,33 @@
|
|||||||
"tailwindcss": "4.1.8"
|
"tailwindcss": "4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.13.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
@ -2876,6 +2916,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bind-event-listener": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@ -6261,6 +6307,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/raf-schd": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||||
"proxy": "http://localhost:8080",
|
"proxy": "http://localhost:8080",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
@ -13,6 +14,7 @@
|
|||||||
"@mui/icons-material": "^7.1.0",
|
"@mui/icons-material": "^7.1.0",
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.0",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
@ -34,8 +36,6 @@
|
|||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx tsc --noEmit && vite",
|
|
||||||
"build": "npx tsc --noEmit && vite build",
|
|
||||||
"dev": "npx tsc --noEmit && vite",
|
"dev": "npx tsc --noEmit && vite",
|
||||||
"build": "npx tsc --noEmit && vite build",
|
"build": "npx tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
@ -752,61 +752,34 @@ const FileEditor = ({
|
|||||||
<SkeletonLoader type="fileGrid" count={6} />
|
<SkeletonLoader type="fileGrid" count={6} />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<DragDropGrid
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem', padding: '1rem' }}>
|
||||||
items={files}
|
{files.map((file, index) => (
|
||||||
selectedItems={localSelectedIds as any /* FIX ME */}
|
<FileThumbnail
|
||||||
selectionMode={selectionMode}
|
key={file.id}
|
||||||
isAnimating={isAnimating}
|
file={file}
|
||||||
onDragStart={handleDragStart as any /* FIX ME */}
|
index={index}
|
||||||
onDragEnd={handleDragEnd}
|
totalFiles={files.length}
|
||||||
onDragOver={handleDragOver}
|
selectedFiles={localSelectedIds}
|
||||||
onDragEnter={handleDragEnter as any /* FIX ME */}
|
selectionMode={selectionMode}
|
||||||
onDragLeave={handleDragLeave}
|
draggedFile={draggedFile}
|
||||||
onDrop={handleDrop as any /* FIX ME */}
|
dropTarget={dropTarget}
|
||||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
isAnimating={isAnimating}
|
||||||
draggedItem={draggedFile as any /* FIX ME */}
|
fileRefs={fileRefs}
|
||||||
dropTarget={dropTarget as any /* FIX ME */}
|
onDragStart={handleDragStart}
|
||||||
multiItemDrag={multiFileDrag as any /* FIX ME */}
|
onDragEnd={handleDragEnd}
|
||||||
dragPosition={dragPosition}
|
onDragOver={handleDragOver}
|
||||||
renderItem={(file, index, refs) => (
|
onDragEnter={handleDragEnter}
|
||||||
<FileThumbnail
|
onDragLeave={handleDragLeave}
|
||||||
file={file}
|
onDrop={handleDrop}
|
||||||
index={index}
|
onToggleFile={toggleFile}
|
||||||
totalFiles={files.length}
|
onDeleteFile={handleDeleteFile}
|
||||||
selectedFiles={localSelectedIds}
|
onViewFile={handleViewFile}
|
||||||
selectionMode={selectionMode}
|
onSetStatus={setStatus}
|
||||||
draggedFile={draggedFile}
|
toolMode={toolMode}
|
||||||
dropTarget={dropTarget}
|
isSupported={isFileSupported(file.name)}
|
||||||
isAnimating={isAnimating}
|
/>
|
||||||
fileRefs={refs}
|
))}
|
||||||
onDragStart={handleDragStart}
|
</div>
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onToggleFile={toggleFile}
|
|
||||||
onDeleteFile={handleDeleteFile}
|
|
||||||
onViewFile={handleViewFile}
|
|
||||||
onSetStatus={setStatus}
|
|
||||||
toolMode={toolMode}
|
|
||||||
isSupported={isFileSupported(file.name)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderSplitMarker={(file, index) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '2px',
|
|
||||||
height: '24rem',
|
|
||||||
borderLeft: '2px dashed #3b82f6',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
marginLeft: '-0.75rem',
|
|
||||||
marginRight: '-0.75rem',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
|
|
||||||
interface DragDropItem {
|
interface DragDropItem {
|
||||||
@ -12,19 +14,9 @@ interface DragDropGridProps<T extends DragDropItem> {
|
|||||||
selectedItems: number[];
|
selectedItems: number[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
onDragStart: (pageNumber: number) => void;
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
||||||
onDragEnd: () => void;
|
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
|
||||||
onDragEnter: (pageNumber: number) => void;
|
|
||||||
onDragLeave: () => void;
|
|
||||||
onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void;
|
|
||||||
onEndZoneDragEnter: () => void;
|
|
||||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||||
draggedItem: number | null;
|
|
||||||
dropTarget: number | 'end' | null;
|
|
||||||
multiItemDrag: {pageNumbers: number[], count: number} | null;
|
|
||||||
dragPosition: {x: number, y: number} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DragDropGrid = <T extends DragDropItem>({
|
const DragDropGrid = <T extends DragDropItem>({
|
||||||
@ -32,217 +24,88 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
selectedItems,
|
selectedItems,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
isAnimating,
|
isAnimating,
|
||||||
onDragStart,
|
onReorderPages,
|
||||||
onDragEnd,
|
|
||||||
onDragOver,
|
|
||||||
onDragEnter,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onEndZoneDragEnter,
|
|
||||||
renderItem,
|
renderItem,
|
||||||
renderSplitMarker,
|
renderSplitMarker,
|
||||||
draggedItem,
|
|
||||||
dropTarget,
|
|
||||||
multiItemDrag,
|
|
||||||
dragPosition,
|
|
||||||
}: DragDropGridProps<T>) => {
|
}: DragDropGridProps<T>) => {
|
||||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [scrollTop, setScrollTop] = useState(0);
|
|
||||||
|
|
||||||
// Virtualization configuration - adjust for document size
|
|
||||||
const isLargeDocument = items.length > 1000; // Only virtualize for very large documents
|
|
||||||
const ITEM_HEIGHT = 340; // Height of PageThumbnail + gap (20rem + gap)
|
|
||||||
const ITEMS_PER_ROW = 4; // Approximate items per row
|
|
||||||
const BUFFER_SIZE = isLargeDocument ? 2 : 3; // Larger buffer for smoother scrolling
|
|
||||||
const OVERSCAN = ITEMS_PER_ROW * BUFFER_SIZE; // Total buffer items
|
|
||||||
|
|
||||||
// Log virtualization stats for debugging
|
// Grid configuration
|
||||||
React.useEffect(() => {
|
const ITEMS_PER_ROW = 4;
|
||||||
if (items.length > 100) {
|
const ITEM_HEIGHT = 340; // 20rem + gap
|
||||||
console.log(`📊 DragDropGrid: Virtualizing ${items.length} items (large doc: ${isLargeDocument}, buffer: ${BUFFER_SIZE})`);
|
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
|
||||||
}
|
|
||||||
}, [items.length, isLargeDocument, BUFFER_SIZE]);
|
|
||||||
|
|
||||||
// Throttled scroll handler to prevent excessive re-renders
|
// Virtualization with react-virtual library
|
||||||
const throttleRef = useRef<number | undefined>(undefined);
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: Math.ceil(items.length / ITEMS_PER_ROW),
|
||||||
|
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
||||||
|
estimateSize: () => ITEM_HEIGHT,
|
||||||
|
overscan: OVERSCAN,
|
||||||
|
});
|
||||||
|
|
||||||
// Detect scroll position from parent container
|
|
||||||
useEffect(() => {
|
|
||||||
const updateScrollPosition = () => {
|
|
||||||
// Throttle scroll updates for better performance
|
|
||||||
if (throttleRef.current) {
|
|
||||||
cancelAnimationFrame(throttleRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
throttleRef.current = requestAnimationFrame(() => {
|
|
||||||
const scrollingParent = containerRef.current?.closest('[data-scrolling-container]') ||
|
|
||||||
containerRef.current?.offsetParent?.closest('div[style*="overflow"]');
|
|
||||||
|
|
||||||
if (scrollingParent) {
|
|
||||||
setScrollTop(scrollingParent.scrollTop || 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollingParent = containerRef.current?.closest('[data-scrolling-container]') ||
|
|
||||||
containerRef.current?.offsetParent?.closest('div[style*="overflow"]');
|
|
||||||
|
|
||||||
if (scrollingParent) {
|
|
||||||
// Use passive listener for better scrolling performance
|
|
||||||
scrollingParent.addEventListener('scroll', updateScrollPosition, { passive: true });
|
|
||||||
updateScrollPosition(); // Initial position
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scrollingParent.removeEventListener('scroll', updateScrollPosition);
|
|
||||||
if (throttleRef.current) {
|
|
||||||
cancelAnimationFrame(throttleRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Calculate visible range with virtualization (only for very large documents)
|
|
||||||
const { startIndex, endIndex, totalHeight, topSpacer } = useMemo(() => {
|
|
||||||
// Skip virtualization for smaller documents to avoid jankiness
|
|
||||||
if (!isLargeDocument) {
|
|
||||||
return {
|
|
||||||
startIndex: 0,
|
|
||||||
endIndex: items.length,
|
|
||||||
totalHeight: Math.ceil(items.length / ITEMS_PER_ROW) * ITEM_HEIGHT,
|
|
||||||
topSpacer: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerHeight = containerRef.current?.clientHeight || 600;
|
|
||||||
const rowHeight = ITEM_HEIGHT;
|
|
||||||
const totalRows = Math.ceil(items.length / ITEMS_PER_ROW);
|
|
||||||
const visibleRows = Math.ceil(containerHeight / rowHeight);
|
|
||||||
|
|
||||||
const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER_SIZE);
|
|
||||||
const endRow = Math.min(totalRows, startRow + visibleRows + BUFFER_SIZE * 2);
|
|
||||||
|
|
||||||
const startIndex = startRow * ITEMS_PER_ROW;
|
|
||||||
const endIndex = Math.min(items.length, endRow * ITEMS_PER_ROW);
|
|
||||||
const totalHeight = totalRows * rowHeight;
|
|
||||||
const topSpacer = startRow * rowHeight;
|
|
||||||
|
|
||||||
return { startIndex, endIndex, totalHeight, topSpacer };
|
|
||||||
}, [items.length, scrollTop, ITEM_HEIGHT, ITEMS_PER_ROW, BUFFER_SIZE, isLargeDocument]);
|
|
||||||
|
|
||||||
// Only render visible items for performance
|
|
||||||
const visibleItems = useMemo(() => {
|
|
||||||
const visible = items.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
// Debug logging for large documents
|
|
||||||
if (items.length > 500 && visible.length > 0) {
|
|
||||||
console.log(`📊 DragDropGrid: Rendering ${visible.length} items (${startIndex}-${endIndex-1}) of ${items.length} total`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return visible;
|
|
||||||
}, [items, startIndex, endIndex]);
|
|
||||||
|
|
||||||
// Global drag cleanup
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGlobalDragEnd = () => {
|
|
||||||
onDragEnd();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGlobalDrop = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (draggedItem) {
|
|
||||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
|
||||||
document.addEventListener('drop', handleGlobalDrop);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
|
||||||
document.removeEventListener('drop', handleGlobalDrop);
|
|
||||||
};
|
|
||||||
}, [draggedItem, onDragEnd]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
// Performance optimizations for smooth scrolling
|
// Basic container styles
|
||||||
transform: 'translateZ(0)', // Force hardware acceleration
|
width: '100%',
|
||||||
backfaceVisibility: 'hidden', // Better rendering performance
|
height: '100%',
|
||||||
WebkitOverflowScrolling: 'touch', // Smooth scrolling on iOS
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: totalHeight,
|
|
||||||
paddingBottom: '100px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top spacer for virtualization */}
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
<div style={{ height: topSpacer }} />
|
const startIndex = virtualRow.index * ITEMS_PER_ROW;
|
||||||
|
const endIndex = Math.min(startIndex + ITEMS_PER_ROW, items.length);
|
||||||
|
const rowItems = items.slice(startIndex, endIndex);
|
||||||
|
|
||||||
{/* Visible items container */}
|
return (
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '1.5rem',
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
// Prevent layout shifts during scrolling
|
|
||||||
containIntrinsicSize: '20rem 20rem',
|
|
||||||
contain: 'layout style',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibleItems.map((item, visibleIndex) => {
|
|
||||||
const actualIndex = startIndex + visibleIndex;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={item.id}>
|
|
||||||
{/* Split marker */}
|
|
||||||
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
|
|
||||||
|
|
||||||
{/* Item */}
|
|
||||||
{renderItem(item, actualIndex, itemRefs)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* End drop zone - inline with pages */}
|
|
||||||
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
|
||||||
<div
|
<div
|
||||||
data-drop-zone="end"
|
key={virtualRow.index}
|
||||||
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
style={{
|
||||||
dropTarget === 'end'
|
position: 'absolute',
|
||||||
? 'ring-2 ring-green-500 bg-green-50'
|
top: 0,
|
||||||
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
left: 0,
|
||||||
}`}
|
width: '100%',
|
||||||
style={{ borderRadius: '12px' }}
|
height: `${virtualRow.size}px`,
|
||||||
onDragOver={onDragOver}
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
onDragEnter={onEndZoneDragEnter}
|
}}
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={(e) => onDrop(e, 'end')}
|
|
||||||
>
|
>
|
||||||
<div className="text-gray-500 text-sm text-center font-medium">
|
<div
|
||||||
Drop here to<br />move to end
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1.5rem',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowItems.map((item, itemIndex) => {
|
||||||
|
const actualIndex = startIndex + itemIndex;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
{/* Split marker */}
|
||||||
|
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
|
||||||
|
{/* Item */}
|
||||||
|
{renderItem(item, actualIndex, itemRefs)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-item drag indicator */}
|
|
||||||
{multiItemDrag && dragPosition && (
|
|
||||||
<div
|
|
||||||
className={styles.multiDragIndicator}
|
|
||||||
style={{
|
|
||||||
left: dragPosition.x,
|
|
||||||
top: dragPosition.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{multiItemDrag.count} items
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -232,11 +232,6 @@ const PageEditor = ({
|
|||||||
const [csvInput, setCsvInput] = useState<string>("");
|
const [csvInput, setCsvInput] = useState<string>("");
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
|
||||||
// Drag and drop state
|
|
||||||
const [draggedPage, setDraggedPage] = useState<number | null>(null);
|
|
||||||
const [dropTarget, setDropTarget] = useState<number | 'end' | null>(null);
|
|
||||||
const [multiPageDrag, setMultiPageDrag] = useState<{pageNumbers: number[], count: number} | null>(null);
|
|
||||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
|
||||||
|
|
||||||
// Export state
|
// Export state
|
||||||
const [exportLoading, setExportLoading] = useState(false);
|
const [exportLoading, setExportLoading] = useState(false);
|
||||||
@ -287,7 +282,7 @@ const PageEditor = ({
|
|||||||
|
|
||||||
// Simple cache-first thumbnail generation (no complex detection needed)
|
// Simple cache-first thumbnail generation (no complex detection needed)
|
||||||
|
|
||||||
// Simple thumbnail generation - generate pages 2+ that aren't cached
|
// Lazy thumbnail generation - only generate when needed, with intelligent batching
|
||||||
const generateMissingThumbnails = useCallback(async () => {
|
const generateMissingThumbnails = useCallback(async () => {
|
||||||
if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) {
|
if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) {
|
||||||
return;
|
return;
|
||||||
@ -299,7 +294,34 @@ const PageEditor = ({
|
|||||||
const totalPages = mergedPdfDocument.totalPages;
|
const totalPages = mergedPdfDocument.totalPages;
|
||||||
if (totalPages <= 1) return; // Only page 1, nothing to generate
|
if (totalPages <= 1) return; // Only page 1, nothing to generate
|
||||||
|
|
||||||
// Check which pages 2+ need thumbnails (not in cache)
|
// For very large documents (2000+ pages), be much more conservative
|
||||||
|
const isVeryLargeDocument = totalPages > 2000;
|
||||||
|
|
||||||
|
if (isVeryLargeDocument) {
|
||||||
|
console.log(`📸 PageEditor: Very large document (${totalPages} pages) - using minimal thumbnail generation`);
|
||||||
|
// For very large docs, only generate the next visible batch (pages 2-25) to avoid UI blocking
|
||||||
|
const pageNumbersToGenerate = [];
|
||||||
|
for (let pageNum = 2; pageNum <= Math.min(25, totalPages); pageNum++) {
|
||||||
|
const pageId = `${primaryFileId}-page-${pageNum}`;
|
||||||
|
if (!getThumbnailFromCache(pageId)) {
|
||||||
|
pageNumbersToGenerate.push(pageNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageNumbersToGenerate.length > 0) {
|
||||||
|
console.log(`📸 PageEditor: Generating initial batch for large doc: pages [${pageNumbersToGenerate.join(', ')}]`);
|
||||||
|
await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule remaining thumbnails with delay to avoid blocking
|
||||||
|
setTimeout(() => {
|
||||||
|
generateRemainingThumbnailsLazily(file, primaryFileId, totalPages, 26);
|
||||||
|
}, 2000); // 2 second delay before starting background generation
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For smaller documents, check which pages 2+ need thumbnails
|
||||||
const pageNumbersToGenerate = [];
|
const pageNumbersToGenerate = [];
|
||||||
for (let pageNum = 2; pageNum <= totalPages; pageNum++) {
|
for (let pageNum = 2; pageNum <= totalPages; pageNum++) {
|
||||||
const pageId = `${primaryFileId}-page-${pageNum}`;
|
const pageId = `${primaryFileId}-page-${pageNum}`;
|
||||||
@ -313,19 +335,23 @@ const PageEditor = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📸 PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.join(', ')}]`);
|
console.log(`📸 PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.slice(0, 5).join(', ')}${pageNumbersToGenerate.length > 5 ? '...' : ''}]`);
|
||||||
|
await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate);
|
||||||
|
}, [mergedPdfDocument, primaryFileId, activeFileIds, selectors]);
|
||||||
|
|
||||||
|
// Helper function to generate thumbnails in batches
|
||||||
|
const generateThumbnailBatch = useCallback(async (file: File, fileId: string, pageNumbers: number[]) => {
|
||||||
try {
|
try {
|
||||||
// Load PDF array buffer for Web Workers
|
// Load PDF array buffer for Web Workers
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
// Calculate quality scale based on file size
|
// Calculate quality scale based on file size
|
||||||
const scale = calculateScaleFromFileSize(selectors.getFileRecord(primaryFileId)?.size || 0);
|
const scale = calculateScaleFromFileSize(selectors.getFileRecord(fileId)?.size || 0);
|
||||||
|
|
||||||
// Start parallel thumbnail generation WITHOUT blocking the main thread
|
// Start parallel thumbnail generation WITHOUT blocking the main thread
|
||||||
await generateThumbnails(
|
await generateThumbnails(
|
||||||
arrayBuffer,
|
arrayBuffer,
|
||||||
pageNumbersToGenerate,
|
pageNumbers,
|
||||||
{
|
{
|
||||||
scale, // Dynamic quality based on file size
|
scale, // Dynamic quality based on file size
|
||||||
quality: 0.8,
|
quality: 0.8,
|
||||||
@ -338,7 +364,7 @@ const PageEditor = ({
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
|
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
|
||||||
// Use stable fileId for cache key
|
// Use stable fileId for cache key
|
||||||
const pageId = `${primaryFileId}-page-${pageNumber}`;
|
const pageId = `${fileId}-page-${pageNumber}`;
|
||||||
addThumbnailToCache(pageId, thumbnail);
|
addThumbnailToCache(pageId, thumbnail);
|
||||||
|
|
||||||
// Don't update context state - thumbnails stay in cache only
|
// Don't update context state - thumbnails stay in cache only
|
||||||
@ -349,11 +375,40 @@ const PageEditor = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`📸 PageEditor: Thumbnail generation completed for pages [${pageNumbersToGenerate.join(', ')}]`);
|
console.log(`📸 PageEditor: Thumbnail generation completed for ${pageNumbers.length} pages`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PageEditor: Thumbnail generation failed:', error);
|
console.error('PageEditor: Thumbnail generation failed:', error);
|
||||||
}
|
}
|
||||||
}, [mergedPdfDocument, primaryFileId, activeFileIds, generateThumbnails, getThumbnailFromCache, addThumbnailToCache, selectors]);
|
}, [generateThumbnails, addThumbnailToCache, selectors]);
|
||||||
|
|
||||||
|
// Background generation for remaining pages in very large documents
|
||||||
|
const generateRemainingThumbnailsLazily = useCallback(async (file: File, fileId: string, totalPages: number, startPage: number) => {
|
||||||
|
console.log(`📸 PageEditor: Starting background thumbnail generation from page ${startPage} to ${totalPages}`);
|
||||||
|
|
||||||
|
// Generate in small chunks to avoid blocking
|
||||||
|
const CHUNK_SIZE = 50;
|
||||||
|
for (let start = startPage; start <= totalPages; start += CHUNK_SIZE) {
|
||||||
|
const end = Math.min(start + CHUNK_SIZE - 1, totalPages);
|
||||||
|
const chunkPageNumbers = [];
|
||||||
|
|
||||||
|
for (let pageNum = start; pageNum <= end; pageNum++) {
|
||||||
|
const pageId = `${fileId}-page-${pageNum}`;
|
||||||
|
if (!getThumbnailFromCache(pageId)) {
|
||||||
|
chunkPageNumbers.push(pageNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkPageNumbers.length > 0) {
|
||||||
|
console.log(`📸 PageEditor: Background generating chunk: pages ${start}-${end} (${chunkPageNumbers.length} needed)`);
|
||||||
|
await generateThumbnailBatch(file, fileId, chunkPageNumbers);
|
||||||
|
|
||||||
|
// Small delay between chunks to keep UI responsive
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📸 PageEditor: Background thumbnail generation completed for ${totalPages} pages`);
|
||||||
|
}, [getThumbnailFromCache, generateThumbnailBatch]);
|
||||||
|
|
||||||
// Simple useEffect - just generate missing thumbnails when document is ready
|
// Simple useEffect - just generate missing thumbnails when document is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -388,30 +443,6 @@ const PageEditor = ({
|
|||||||
setCsvInput(newCsvInput);
|
setCsvInput(newCsvInput);
|
||||||
}, [selectedPageNumbers]);
|
}, [selectedPageNumbers]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGlobalDragEnd = () => {
|
|
||||||
// Clean up drag state when drag operation ends anywhere
|
|
||||||
setDraggedPage(null);
|
|
||||||
setDropTarget(null);
|
|
||||||
setMultiPageDrag(null);
|
|
||||||
setDragPosition(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGlobalDrop = (e: DragEvent) => {
|
|
||||||
// Prevent default to handle invalid drops
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (draggedPage) {
|
|
||||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
|
||||||
document.addEventListener('drop', handleGlobalDrop);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
|
||||||
document.removeEventListener('drop', handleGlobalDrop);
|
|
||||||
};
|
|
||||||
}, [draggedPage]);
|
|
||||||
|
|
||||||
const selectAll = useCallback(() => {
|
const selectAll = useCallback(() => {
|
||||||
if (mergedPdfDocument) {
|
if (mergedPdfDocument) {
|
||||||
@ -484,73 +515,8 @@ const PageEditor = ({
|
|||||||
actions.setSelectedPages(pageNumbers);
|
actions.setSelectedPages(pageNumbers);
|
||||||
}, [csvInput, parseCSVInput, actions]);
|
}, [csvInput, parseCSVInput, actions]);
|
||||||
|
|
||||||
const handleDragStart = useCallback((pageNumber: number) => {
|
|
||||||
setDraggedPage(pageNumber);
|
|
||||||
|
|
||||||
// Check if this is a multi-page drag in selection mode
|
|
||||||
if (selectionMode && selectedPageNumbers.includes(pageNumber) && selectedPageNumbers.length > 1) {
|
|
||||||
setMultiPageDrag({
|
|
||||||
pageNumbers: selectedPageNumbers,
|
|
||||||
count: selectedPageNumbers.length
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setMultiPageDrag(null);
|
|
||||||
}
|
|
||||||
}, [selectionMode, selectedPageNumbers]);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
|
||||||
// Clean up drag state regardless of where the drop happened
|
|
||||||
setDraggedPage(null);
|
|
||||||
setDropTarget(null);
|
|
||||||
setMultiPageDrag(null);
|
|
||||||
setDragPosition(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!draggedPage) return;
|
|
||||||
|
|
||||||
// Update drag position for multi-page indicator
|
|
||||||
if (multiPageDrag) {
|
|
||||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the element under the mouse cursor
|
|
||||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
|
||||||
if (!elementUnderCursor) return;
|
|
||||||
|
|
||||||
// Find the closest page container
|
|
||||||
const pageContainer = elementUnderCursor.closest('[data-page-number]');
|
|
||||||
if (pageContainer) {
|
|
||||||
const pageNumberStr = pageContainer.getAttribute('data-page-number');
|
|
||||||
const pageNumber = pageNumberStr ? parseInt(pageNumberStr) : null;
|
|
||||||
if (pageNumber && pageNumber !== draggedPage) {
|
|
||||||
setDropTarget(pageNumber);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if over the end zone
|
|
||||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
|
||||||
if (endZone) {
|
|
||||||
setDropTarget('end');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not over any valid drop target, clear it
|
|
||||||
setDropTarget(null);
|
|
||||||
}, [draggedPage, multiPageDrag]);
|
|
||||||
|
|
||||||
const handleDragEnter = useCallback((pageNumber: number) => {
|
|
||||||
if (draggedPage && pageNumber !== draggedPage) {
|
|
||||||
setDropTarget(pageNumber);
|
|
||||||
}
|
|
||||||
}, [draggedPage]);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback(() => {
|
|
||||||
// Don't clear drop target on drag leave - let dragover handle it
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update PDF document state with edit tracking
|
// Update PDF document state with edit tracking
|
||||||
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
|
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
|
||||||
@ -791,34 +757,22 @@ const PageEditor = ({
|
|||||||
}, 10); // Small delay to allow state update
|
}, 10); // Small delay to allow state update
|
||||||
}, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]);
|
}, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]);
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => {
|
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => {
|
||||||
e.preventDefault();
|
if (!displayDocument) return;
|
||||||
if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return;
|
|
||||||
|
|
||||||
let targetIndex: number;
|
const pagesToMove = selectedPages && selectedPages.length > 1
|
||||||
if (targetPageNumber === 'end') {
|
? selectedPages
|
||||||
targetIndex = displayDocument.pages.length;
|
: [sourcePageNumber];
|
||||||
} else {
|
|
||||||
targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
|
||||||
if (targetIndex === -1) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
animateReorder(draggedPage, targetIndex);
|
const sourceIndex = displayDocument.pages.findIndex(p => p.pageNumber === sourcePageNumber);
|
||||||
|
if (sourceIndex === -1 || sourceIndex === targetIndex) return;
|
||||||
|
|
||||||
setDraggedPage(null);
|
animateReorder(sourcePageNumber, targetIndex);
|
||||||
setDropTarget(null);
|
|
||||||
setMultiPageDrag(null);
|
|
||||||
setDragPosition(null);
|
|
||||||
|
|
||||||
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
|
const moveCount = pagesToMove.length;
|
||||||
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
||||||
}, [draggedPage, displayDocument, animateReorder, multiPageDrag]);
|
}, [displayDocument, animateReorder]);
|
||||||
|
|
||||||
const handleEndZoneDragEnter = useCallback(() => {
|
|
||||||
if (draggedPage) {
|
|
||||||
setDropTarget('end');
|
|
||||||
}
|
|
||||||
}, [draggedPage]);
|
|
||||||
|
|
||||||
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
||||||
if (!displayDocument) return;
|
if (!displayDocument) return;
|
||||||
@ -1058,6 +1012,13 @@ const PageEditor = ({
|
|||||||
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
|
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
|
||||||
// Use centralized IndexedDB manager
|
// Use centralized IndexedDB manager
|
||||||
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
||||||
|
|
||||||
|
// Check if the drafts object store exists before using it
|
||||||
|
if (!db.objectStoreNames.contains('drafts')) {
|
||||||
|
console.log('📝 Drafts object store not found, skipping draft check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const transaction = db.transaction('drafts', 'readonly');
|
const transaction = db.transaction('drafts', 'readonly');
|
||||||
const store = transaction.objectStore('drafts');
|
const store = transaction.objectStore('drafts');
|
||||||
const getRequest = store.get(draftKey);
|
const getRequest = store.get(draftKey);
|
||||||
@ -1288,17 +1249,7 @@ const PageEditor = ({
|
|||||||
selectedItems={selectedPageNumbers}
|
selectedItems={selectedPageNumbers}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
isAnimating={isAnimating}
|
isAnimating={isAnimating}
|
||||||
onDragStart={handleDragStart}
|
onReorderPages={handleReorderPages}
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
|
||||||
draggedItem={draggedPage}
|
|
||||||
dropTarget={dropTarget}
|
|
||||||
multiItemDrag={multiPageDrag}
|
|
||||||
dragPosition={dragPosition}
|
|
||||||
renderItem={(page, index, refs) => (
|
renderItem={(page, index, refs) => (
|
||||||
<PageThumbnail
|
<PageThumbnail
|
||||||
page={page}
|
page={page}
|
||||||
@ -1307,17 +1258,10 @@ const PageEditor = ({
|
|||||||
originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined}
|
originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined}
|
||||||
selectedPages={selectedPageNumbers}
|
selectedPages={selectedPageNumbers}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
draggedPage={draggedPage}
|
|
||||||
dropTarget={dropTarget === 'end' ? null : dropTarget}
|
|
||||||
movingPage={movingPage}
|
movingPage={movingPage}
|
||||||
isAnimating={isAnimating}
|
isAnimating={isAnimating}
|
||||||
pageRefs={refs}
|
pageRefs={refs}
|
||||||
onDragStart={handleDragStart}
|
onReorderPages={handleReorderPages}
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onTogglePage={togglePage}
|
onTogglePage={togglePage}
|
||||||
onAnimateReorder={animateReorder}
|
onAnimateReorder={animateReorder}
|
||||||
onExecuteCommand={executeCommand}
|
onExecuteCommand={executeCommand}
|
||||||
|
@ -6,7 +6,7 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
|||||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
||||||
import { Command } from '../../hooks/useUndoRedo';
|
import { Command } from '../../hooks/useUndoRedo';
|
||||||
@ -27,22 +27,15 @@ interface PageThumbnailProps {
|
|||||||
originalFile?: File; // For lazy thumbnail generation
|
originalFile?: File; // For lazy thumbnail generation
|
||||||
selectedPages: number[];
|
selectedPages: number[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
draggedPage: number | null;
|
|
||||||
dropTarget: number | 'end' | null;
|
|
||||||
movingPage: number | null;
|
movingPage: number | null;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
onDragStart: (pageNumber: number) => void;
|
|
||||||
onDragEnd: () => void;
|
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
|
||||||
onDragEnter: (pageNumber: number) => void;
|
|
||||||
onDragLeave: () => void;
|
|
||||||
onDrop: (e: React.DragEvent, pageNumber: number) => void;
|
|
||||||
onTogglePage: (pageNumber: number) => void;
|
onTogglePage: (pageNumber: number) => void;
|
||||||
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
||||||
onExecuteCommand: (command: Command) => void;
|
onExecuteCommand: (command: Command) => void;
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
onSetMovingPage: (pageNumber: number | null) => void;
|
onSetMovingPage: (pageNumber: number | null) => void;
|
||||||
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
||||||
RotatePagesCommand: typeof RotatePagesCommand;
|
RotatePagesCommand: typeof RotatePagesCommand;
|
||||||
DeletePagesCommand: typeof DeletePagesCommand;
|
DeletePagesCommand: typeof DeletePagesCommand;
|
||||||
ToggleSplitCommand: typeof ToggleSplitCommand;
|
ToggleSplitCommand: typeof ToggleSplitCommand;
|
||||||
@ -57,22 +50,15 @@ const PageThumbnail = React.memo(({
|
|||||||
originalFile,
|
originalFile,
|
||||||
selectedPages,
|
selectedPages,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
draggedPage,
|
|
||||||
dropTarget,
|
|
||||||
movingPage,
|
movingPage,
|
||||||
isAnimating,
|
isAnimating,
|
||||||
pageRefs,
|
pageRefs,
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
onDragOver,
|
|
||||||
onDragEnter,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onTogglePage,
|
onTogglePage,
|
||||||
onAnimateReorder,
|
onAnimateReorder,
|
||||||
onExecuteCommand,
|
onExecuteCommand,
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
onSetMovingPage,
|
onSetMovingPage,
|
||||||
|
onReorderPages,
|
||||||
RotatePagesCommand,
|
RotatePagesCommand,
|
||||||
DeletePagesCommand,
|
DeletePagesCommand,
|
||||||
ToggleSplitCommand,
|
ToggleSplitCommand,
|
||||||
@ -80,6 +66,8 @@ const PageThumbnail = React.memo(({
|
|||||||
setPdfDocument,
|
setPdfDocument,
|
||||||
}: PageThumbnailProps) => {
|
}: PageThumbnailProps) => {
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||||
|
|
||||||
@ -130,14 +118,69 @@ const PageThumbnail = React.memo(({
|
|||||||
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
|
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
|
||||||
|
|
||||||
|
|
||||||
// Register this component with pageRefs for animations
|
|
||||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
pageRefs.current.set(page.id, element);
|
pageRefs.current.set(page.id, element);
|
||||||
|
dragElementRef.current = element;
|
||||||
|
|
||||||
|
const dragCleanup = draggable({
|
||||||
|
element,
|
||||||
|
getInitialData: () => ({
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
pageId: page.id,
|
||||||
|
selectedPages: selectionMode && selectedPages.includes(page.pageNumber)
|
||||||
|
? selectedPages
|
||||||
|
: [page.pageNumber]
|
||||||
|
}),
|
||||||
|
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) {
|
||||||
|
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
|
||||||
|
? selectedPages
|
||||||
|
: undefined;
|
||||||
|
onReorderPages(page.pageNumber, targetIndex, pagesToMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
element.style.cursor = 'grab';
|
||||||
|
|
||||||
|
|
||||||
|
const dropCleanup = dropTargetForElements({
|
||||||
|
element,
|
||||||
|
getData: () => ({
|
||||||
|
type: 'page',
|
||||||
|
pageNumber: page.pageNumber
|
||||||
|
}),
|
||||||
|
onDrop: ({ source }) => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
(element as any).__dragCleanup = () => {
|
||||||
|
dragCleanup();
|
||||||
|
dropCleanup();
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
pageRefs.current.delete(page.id);
|
pageRefs.current.delete(page.id);
|
||||||
|
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||||
|
(dragElementRef.current as any).__dragCleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [page.id, pageRefs]);
|
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -159,25 +202,13 @@ const PageThumbnail = React.memo(({
|
|||||||
${selectionMode
|
${selectionMode
|
||||||
? 'bg-white hover:bg-gray-50'
|
? 'bg-white hover:bg-gray-50'
|
||||||
: 'bg-white hover:bg-gray-50'}
|
: 'bg-white hover:bg-gray-50'}
|
||||||
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
|
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
transform: (() => {
|
|
||||||
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
|
|
||||||
return 'translateX(20px)';
|
|
||||||
}
|
|
||||||
return 'translateX(0)';
|
|
||||||
})(),
|
|
||||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||||
}}
|
}}
|
||||||
draggable
|
draggable={false}
|
||||||
onDragStart={() => onDragStart(page.pageNumber)}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragEnter={() => onDragEnter(page.pageNumber)}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={(e) => onDrop(e, page.pageNumber)}
|
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<div
|
<div
|
||||||
@ -201,7 +232,6 @@ const PageThumbnail = React.memo(({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
console.log('📸 Checkbox clicked for page', page.pageNumber);
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onTogglePage(page.pageNumber);
|
onTogglePage(page.pageNumber);
|
||||||
}}
|
}}
|
||||||
@ -216,7 +246,7 @@ const PageThumbnail = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="page-container w-[90%] h-[90%]">
|
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -234,6 +264,7 @@ const PageThumbnail = React.memo(({
|
|||||||
<img
|
<img
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={`Page ${page.pageNumber}`}
|
alt={`Page ${page.pageNumber}`}
|
||||||
|
draggable={false}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@ -415,16 +446,6 @@ const PageThumbnail = React.memo(({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragIndicatorIcon
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 4,
|
|
||||||
right: 4,
|
|
||||||
color: 'rgba(0,0,0,0.3)',
|
|
||||||
fontSize: 16,
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -444,8 +465,6 @@ const PageThumbnail = React.memo(({
|
|||||||
(prevProps.selectedPages === nextProps.selectedPages ||
|
(prevProps.selectedPages === nextProps.selectedPages ||
|
||||||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
|
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
|
||||||
prevProps.selectionMode === nextProps.selectionMode &&
|
prevProps.selectionMode === nextProps.selectionMode &&
|
||||||
prevProps.draggedPage === nextProps.draggedPage &&
|
|
||||||
prevProps.dropTarget === nextProps.dropTarget &&
|
|
||||||
prevProps.movingPage === nextProps.movingPage &&
|
prevProps.movingPage === nextProps.movingPage &&
|
||||||
prevProps.isAnimating === nextProps.isAnimating
|
prevProps.isAnimating === nextProps.isAnimating
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user