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:
parent
7bd25e1a7a
commit
d25eb56794
@ -15,5 +15,5 @@
|
||||
"hasMasterSlide": true
|
||||
}
|
||||
},
|
||||
"generated": "2025-09-12T14:15:01.593Z"
|
||||
"generated": "2025-09-24T21:37:00.344Z"
|
||||
}
|
||||
@ -40,7 +40,7 @@ export const PresentationEditor: React.FC = () => {
|
||||
confirmDelete
|
||||
} = useDialog();
|
||||
|
||||
const { duplicateSlide, deleteSlide, saving } = useSlideOperations({
|
||||
const { duplicateSlide, deleteSlide, reorderSlides, saving } = useSlideOperations({
|
||||
presentation,
|
||||
presentationId: presentationId || '',
|
||||
onPresentationUpdate: setPresentation,
|
||||
@ -176,6 +176,7 @@ export const PresentationEditor: React.FC = () => {
|
||||
onEditSlide={(slideId) => navigate(`/presentations/${presentationId}/slide/${slideId}/edit`)}
|
||||
onDuplicateSlide={duplicateSlide}
|
||||
onDeleteSlide={deleteSlide}
|
||||
onReorderSlides={reorderSlides}
|
||||
/>
|
||||
|
||||
<div className="slide-editor-area">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Slide } from '../../types/presentation.ts';
|
||||
import { SlideThumbnail } from './shared/SlideThumbnail.tsx';
|
||||
import './SlidesSidebar.css';
|
||||
@ -13,6 +13,7 @@ interface SlidesSidebarProps {
|
||||
onEditSlide: (slideId: string) => void;
|
||||
onDuplicateSlide: (index: number) => void;
|
||||
onDeleteSlide: (index: number) => void;
|
||||
onReorderSlides: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({
|
||||
@ -24,8 +25,58 @@ export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({
|
||||
onAddSlide,
|
||||
onEditSlide,
|
||||
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 (
|
||||
<aside className="slide-sidebar">
|
||||
<div className="sidebar-header">
|
||||
@ -48,11 +99,18 @@ export const SlidesSidebar: React.FC<SlidesSidebarProps> = ({
|
||||
index={index}
|
||||
isActive={index === currentSlideIndex}
|
||||
isDisabled={saving}
|
||||
isDragged={draggedIndex === index}
|
||||
isDraggedOver={dragOverIndex === index}
|
||||
onClick={() => onSlideClick(index)}
|
||||
onDoubleClick={() => onSlideDoubleClick(slide.id)}
|
||||
onEdit={() => onEditSlide(slide.id)}
|
||||
onDuplicate={() => onDuplicateSlide(index)}
|
||||
onDelete={() => onDeleteSlide(index)}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -114,4 +114,60 @@
|
||||
|
||||
.thumbnail-action.delete:hover:not(:disabled) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -7,11 +7,18 @@ interface SlideThumbnailProps {
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isDisabled?: boolean;
|
||||
isDragged?: boolean;
|
||||
isDraggedOver?: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => 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> = ({
|
||||
@ -19,17 +26,50 @@ export const SlideThumbnail: React.FC<SlideThumbnailProps> = ({
|
||||
index,
|
||||
isActive,
|
||||
isDisabled = false,
|
||||
isDragged = false,
|
||||
isDraggedOver = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onEdit,
|
||||
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 (
|
||||
<div
|
||||
className={`slide-thumbnail ${isActive ? 'active' : ''}`}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={className}
|
||||
draggable={!isDisabled}
|
||||
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-preview">
|
||||
@ -78,6 +118,12 @@ export const SlideThumbnail: React.FC<SlideThumbnailProps> = ({
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isDisabled && (
|
||||
<div className="drag-handle" title="Drag to reorder">
|
||||
⋮⋮
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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 {
|
||||
duplicateSlide,
|
||||
deleteSlide,
|
||||
reorderSlides,
|
||||
saving
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user