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
|
"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
|
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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -115,3 +115,59 @@
|
|||||||
.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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Loading…
Reference in New Issue
Block a user