feat: Add drag-and-drop slide reordering functionality

- Add reorderSlides function to useSlideOperations hook
- Update SlidesSidebar to handle drag-and-drop events
- Make SlideThumbnail components draggable with visual feedback
- Add CSS styles for drag states (dragged, drag-over, drag handle)
- Implement proper slide order management and persistence
- Add visual indicators for drag operations (opacity, transform, borders)
- Disable drag operations when saving to prevent conflicts
This completes the slide reordering feature from the user flows document.
This commit is contained in:
Michael Mainguy 2025-09-27 16:48:17 -05:00
parent 7bd25e1a7a
commit d25eb56794
7 changed files with 210 additions and 9 deletions

1
Procfile Normal file
View File

@ -0,0 +1 @@
web: npm run build && npm preview

View File

@ -15,5 +15,5 @@
"hasMasterSlide": true "hasMasterSlide": true
} }
}, },
"generated": "2025-09-12T14:15:01.593Z" "generated": "2025-09-24T21:37:00.344Z"
} }

View File

@ -40,7 +40,7 @@ export const PresentationEditor: React.FC = () => {
confirmDelete confirmDelete
} = useDialog(); } = useDialog();
const { duplicateSlide, deleteSlide, saving } = useSlideOperations({ const { duplicateSlide, deleteSlide, reorderSlides, saving } = useSlideOperations({
presentation, presentation,
presentationId: presentationId || '', presentationId: presentationId || '',
onPresentationUpdate: setPresentation, onPresentationUpdate: setPresentation,
@ -176,6 +176,7 @@ export const PresentationEditor: React.FC = () => {
onEditSlide={(slideId) => navigate(`/presentations/${presentationId}/slide/${slideId}/edit`)} onEditSlide={(slideId) => navigate(`/presentations/${presentationId}/slide/${slideId}/edit`)}
onDuplicateSlide={duplicateSlide} onDuplicateSlide={duplicateSlide}
onDeleteSlide={deleteSlide} onDeleteSlide={deleteSlide}
onReorderSlides={reorderSlides}
/> />
<div className="slide-editor-area"> <div className="slide-editor-area">

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import type { Slide } from '../../types/presentation.ts'; import type { Slide } from '../../types/presentation.ts';
import { SlideThumbnail } from './shared/SlideThumbnail.tsx'; import { SlideThumbnail } from './shared/SlideThumbnail.tsx';
import './SlidesSidebar.css'; import './SlidesSidebar.css';
@ -13,6 +13,7 @@ interface SlidesSidebarProps {
onEditSlide: (slideId: string) => void; onEditSlide: (slideId: string) => void;
onDuplicateSlide: (index: number) => void; onDuplicateSlide: (index: number) => void;
onDeleteSlide: (index: number) => void; onDeleteSlide: (index: number) => void;
onReorderSlides: (fromIndex: number, toIndex: number) => void;
} }
export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({ export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({
@ -24,8 +25,58 @@ export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({
onAddSlide, onAddSlide,
onEditSlide, onEditSlide,
onDuplicateSlide, onDuplicateSlide,
onDeleteSlide onDeleteSlide,
onReorderSlides
}) => { }) => {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleDragStart = (e: React.DragEvent, index: number) => {
if (saving) return;
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index.toString());
// Add drag image
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = '0.5';
}
};
const handleDragEnd = (e: React.DragEvent) => {
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = '';
}
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedIndex !== null && index !== draggedIndex) {
setDragOverIndex(index);
}
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, toIndex: number) => {
e.preventDefault();
const fromIndex = draggedIndex;
if (fromIndex !== null && fromIndex !== toIndex && !saving) {
onReorderSlides(fromIndex, toIndex);
}
setDraggedIndex(null);
setDragOverIndex(null);
};
return ( return (
<aside className="slide-sidebar"> <aside className="slide-sidebar">
<div className="sidebar-header"> <div className="sidebar-header">
@ -48,11 +99,18 @@ export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({
index={index} index={index}
isActive={index === currentSlideIndex} isActive={index === currentSlideIndex}
isDisabled={saving} isDisabled={saving}
isDragged={draggedIndex === index}
isDraggedOver={dragOverIndex === index}
onClick={() => onSlideClick(index)} onClick={() => onSlideClick(index)}
onDoubleClick={() => onSlideDoubleClick(slide.id)} onDoubleClick={() => onSlideDoubleClick(slide.id)}
onEdit={() => onEditSlide(slide.id)} onEdit={() => onEditSlide(slide.id)}
onDuplicate={() => onDuplicateSlide(index)} onDuplicate={() => onDuplicateSlide(index)}
onDelete={() => onDeleteSlide(index)} onDelete={() => onDeleteSlide(index)}
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
/> />
))} ))}
</div> </div>

View File

@ -114,4 +114,60 @@
.thumbnail-action.delete:hover:not(:disabled) { .thumbnail-action.delete:hover:not(:disabled) {
background: var(--color-red-100); background: var(--color-red-100);
} }
/* Drag and Drop Styles */
.slide-thumbnail[draggable="true"] {
cursor: grab;
}
.slide-thumbnail[draggable="true"]:active {
cursor: grabbing;
}
.slide-thumbnail.dragged {
opacity: 0.5;
transform: rotate(2deg);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
.slide-thumbnail.drag-over {
border-color: var(--color-blue-500);
background: var(--color-blue-50);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.3);
}
.slide-thumbnail.drag-over::before {
content: '';
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 2px;
background: var(--color-blue-500);
border-radius: 1px;
}
/* Drag Handle */
.drag-handle {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
color: var(--text-tertiary);
font-size: 0.875rem;
font-weight: bold;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
z-index: 5;
}
.slide-thumbnail:hover .drag-handle {
opacity: 1;
}
.slide-thumbnail[draggable="false"] .drag-handle {
display: none;
}

View File

@ -7,11 +7,18 @@ interface SlideThumbnailProps {
index: number; index: number;
isActive: boolean; isActive: boolean;
isDisabled?: boolean; isDisabled?: boolean;
isDragged?: boolean;
isDraggedOver?: boolean;
onClick: () => void; onClick: () => void;
onDoubleClick?: () => void; onDoubleClick?: () => void;
onEdit: () => void; onEdit: () => void;
onDuplicate: () => void; onDuplicate: () => void;
onDelete: () => void; onDelete: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
onDragOver?: (e: React.DragEvent) => void;
onDragLeave?: (e: React.DragEvent) => void;
onDrop?: (e: React.DragEvent) => void;
} }
export const SlideThumbnail: React.FC<SlideThumbnailProps> = ({ export const SlideThumbnail: React.FC<SlideThumbnailProps> = ({
@ -19,17 +26,50 @@ export const SlideThumbnail: React.FC<SlideThumbnailProps> = ({
index, index,
isActive, isActive,
isDisabled = false, isDisabled = false,
isDragged = false,
isDraggedOver = false,
onClick, onClick,
onDoubleClick, onDoubleClick,
onEdit, onEdit,
onDuplicate, onDuplicate,
onDelete onDelete,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop
}) => { }) => {
const handleClick = () => {
// Don't trigger click during drag operations
if (isDragged) return;
onClick();
};
const handleDoubleClick = () => {
// Don't trigger double-click during drag operations
if (isDragged || !onDoubleClick) return;
onDoubleClick();
};
const className = [
'slide-thumbnail',
isActive && 'active',
isDragged && 'dragged',
isDraggedOver && 'drag-over'
].filter(Boolean).join(' ');
return ( return (
<div <div
className={`slide-thumbnail ${isActive ? 'active' : ''}`} className={className}
onClick={onClick} draggable={!isDisabled}
onDoubleClick={onDoubleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
title={`Slide ${index + 1}${isDragged ? ' (dragging...)' : ''}`}
> >
<div className="thumbnail-number">{index + 1}</div> <div className="thumbnail-number">{index + 1}</div>
<div className="thumbnail-preview"> <div className="thumbnail-preview">
@ -78,6 +118,12 @@ export const SlideThumbnail: React.FC<SlideThumbnailProps> = ({
</button> </button>
</div> </div>
{!isDisabled && (
<div className="drag-handle" title="Drag to reorder">
</div>
)}
</div> </div>
); );
}; };

View File

@ -136,9 +136,48 @@ export const useSlideOperations = ({
} }
}; };
const reorderSlides = async (fromIndex: number, toIndex: number) => {
if (!presentation || fromIndex === toIndex) return;
try {
setSaving(true);
onError('');
// Create updated presentation with reordered slides
const updatedPresentation = { ...presentation };
const newSlides = [...presentation.slides];
// Remove the slide from its current position
const [movedSlide] = newSlides.splice(fromIndex, 1);
// Insert it at the new position
newSlides.splice(toIndex, 0, movedSlide);
// Update slide order for all slides
newSlides.forEach((slide, index) => {
slide.order = index;
});
updatedPresentation.slides = newSlides;
// Save the updated presentation
await updatePresentation(updatedPresentation);
// Update local state
onPresentationUpdate(updatedPresentation);
} catch (err) {
loggers.presentation.error('Failed to reorder slides', err instanceof Error ? err : new Error(String(err)));
onError(err instanceof Error ? err.message : 'Failed to reorder slides');
} finally {
setSaving(false);
}
};
return { return {
duplicateSlide, duplicateSlide,
deleteSlide, deleteSlide,
reorderSlides,
saving saving
}; };
}; };