This commit is contained in:
Reece 2025-10-28 19:38:53 +00:00
parent c6c986ade2
commit 039de2dde2
4 changed files with 235 additions and 162 deletions

View File

@ -0,0 +1,73 @@
.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);
z-index: 1;
}
.dragOverlayPreview {
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
opacity: 0.5;
}

View File

@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { GRID_CONSTANTS } from './constants';
import styles from './DragDropGrid.module.css';
import {
Z_INDEX_SELECTION_BOX,
Z_INDEX_DROP_INDICATOR,
@ -36,6 +37,8 @@ interface DragDropGridProps<T extends DragDropItem> {
type DropSide = 'left' | 'right' | null;
type ItemRect = { id: string; rect: DOMRect };
interface DropHint {
hoveredId: string | null;
dropSide: DropSide;
@ -51,59 +54,87 @@ function resolveDropHint(
return { hoveredId: null, dropSide: null };
}
const rows = new Map<number, Array<{ id: string; rect: DOMRect }>>();
const items: ItemRect[] = Array.from(itemRefs.current.entries())
.filter(([itemId, element]): element is HTMLDivElement => !!element && itemId !== activeId)
.map(([itemId, element]) => ({
id: itemId,
rect: element.getBoundingClientRect(),
}))
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
itemRefs.current.forEach((element, itemId) => {
if (!element || itemId === activeId) return;
const rect = element.getBoundingClientRect();
const rowCenter = rect.top + rect.height / 2;
let row = rows.get(rowCenter);
if (!row) {
row = [];
rows.set(rowCenter, row);
}
row.push({ id: itemId, rect });
});
let hoveredId: string | null = null;
let dropSide: DropSide = null;
let closestRowY = 0;
let closestRowDistance = Infinity;
rows.forEach((_items, rowY) => {
const distance = Math.abs(cursorY - rowY);
if (distance < closestRowDistance) {
closestRowDistance = distance;
closestRowY = rowY;
}
});
const closestRow = rows.get(closestRowY);
if (!closestRow || closestRow.length === 0) {
if (items.length === 0) {
return { hoveredId: null, dropSide: null };
}
let closestDistance = Infinity;
closestRow.forEach(({ id, rect }) => {
const distanceToLeft = Math.abs(cursorX - rect.left);
const distanceToRight = Math.abs(cursorX - rect.right);
items.sort((a, b) => a.rect.top - b.rect.top);
if (distanceToLeft < closestDistance) {
closestDistance = distanceToLeft;
hoveredId = id;
dropSide = 'left';
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;
}
if (distanceToRight < closestDistance) {
closestDistance = distanceToRight;
hoveredId = id;
dropSide = 'right';
const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
if (isSameRow) {
currentRow.push(item);
} else {
rows.push([item]);
}
});
return { hoveredId, dropSide };
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>(
@ -561,14 +592,10 @@ const DragDropGrid = <T extends DragDropItem>({
// Calculate selection box dimensions
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
position: 'absolute' as const,
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),
border: '2px dashed #3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
pointerEvents: 'none' as const,
zIndex: Z_INDEX_SELECTION_BOX,
} : null;
@ -590,15 +617,10 @@ const DragDropGrid = <T extends DragDropItem>({
: itemRect.right - containerRect.left + itemGap / 2;
return {
position: 'absolute' as const,
left: `${left}px`,
top: `${top}px`,
width: '4px',
height: `${height}px`,
backgroundColor: 'rgba(96, 165, 250, 0.8)',
borderRadius: '2px',
zIndex: Z_INDEX_DROP_INDICATOR,
pointerEvents: 'none' as const,
};
}, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]);
@ -638,118 +660,94 @@ const DragDropGrid = <T extends DragDropItem>({
>
<Box
ref={containerRef}
className={styles.gridContainer}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheelWhileDragging}
style={{
// Basic container styles
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Selection box overlay */}
{selectionBoxStyle && <div style={selectionBoxStyle} />}
{selectionBoxStyle && (
<div
className={styles.selectionBox}
style={selectionBoxStyle}
/>
)}
{/* Global drop indicator */}
{dropIndicatorStyle && <div style={dropIndicatorStyle} />}
{dropIndicatorStyle && (
<div
className={styles.dropIndicator}
style={dropIndicatorStyle}
/>
)}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
maxWidth: `${gridWidth}px`,
position: 'relative',
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);
<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}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
return (
<div
key={virtualRow.index}
className={styles.virtualRow}
style={{
display: 'flex',
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
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 (
<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
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 style={{ position: 'relative', cursor: 'grabbing' }}>
{/* Multi-page badge */}
<div className={styles.dragOverlay}>
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
backgroundColor: '#3b82f6',
color: 'white',
borderRadius: '50%',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: 'bold',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
zIndex: Z_INDEX_DRAG_BADGE
}}
className={styles.dragOverlayBadge}
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
>
{boxSelectedPageIds.length}
</div>
)}
{/* Just the thumbnail image */}
{dragPreview ? (
<img
src={dragPreview.src}
@ -764,15 +762,13 @@ const DragDropGrid = <T extends DragDropItem>({
}}
/>
) : (
<div style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '48px',
opacity: 0.5,
}}>
<div
className={styles.dragOverlayPreview}
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
}}
>
??
</div>
)}

View File

@ -55,6 +55,17 @@ const PageEditor = ({
const [zoomLevel, setZoomLevel] = useState(1.0);
const containerRef = useRef<HTMLDivElement>(null);
const [isContainerHovered, setIsContainerHovered] = useState(false);
const rootFontSize = useMemo(() => {
if (typeof window === 'undefined') {
return 16;
}
const computed = getComputedStyle(document.documentElement).fontSize;
const parsed = parseFloat(computed);
return Number.isNaN(parsed) ? 16 : parsed;
}, []);
const itemGapPx = useMemo(() => {
return parseFloat(GRID_CONSTANTS.ITEM_GAP) * rootFontSize * zoomLevel;
}, [rootFontSize, zoomLevel]);
// Zoom actions
const zoomIn = useCallback(() => {
@ -306,9 +317,6 @@ const PageEditor = ({
// Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null);
// State to trigger re-renders when container size changes
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 });
// Undo/Redo state
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
@ -1100,11 +1108,13 @@ const PageEditor = ({
if (sameRow) {
lineLeft = (currentRect.right + nextRect.left) / 2;
} else {
lineLeft = currentRect.right + nextRect.width * 0.1;
lineLeft = currentRect.right + itemGapPx / 2;
}
} else {
lineLeft = currentRect.right + itemGapPx / 2;
}
} else {
lineLeft = currentRect.right + currentRect.width * 0.1;
lineLeft = currentRect.right + itemGapPx / 2;
}
return (

View File

@ -1,9 +1,4 @@
<<<<<<< HEAD:frontend/src/core/types/pageEditor.ts
import { FileId } from '@app/types/file';
=======
import { FileId } from './file';
import { PageBreakSettings } from '../components/pageEditor/commands/pageCommands';
>>>>>>> feature/v2/selected-pageeditor:frontend/src/types/pageEditor.ts
export interface PDFPage {
id: string;
@ -16,7 +11,6 @@ export interface PDFPage {
isBlankPage?: boolean;
isPlaceholder?: boolean;
originalFileId?: FileId;
pageBreakSettings?: PageBreakSettings;
}
export interface PDFDocument {