mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:01:09 +02:00
refactor(calendar): add comprehensive drag/drop composables
Create reusable composables for calendar event and task drag/drop: - useEventDragDrop: Event drag and resize with document-level listeners - useTaskDragDrop: Task drag and resize (updated to match new API) - useSidebarDrop: Sidebar task drop handling - useCalendarKeyboard: Keyboard shortcut handling (Escape to cancel) These composables extract ~600 lines of duplicated logic from WeekView, DayView, and MultiDayView. Integration into views will be done in a follow-up commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
026c1654e3
commit
5bf275d9d0
5 changed files with 807 additions and 173 deletions
|
|
@ -3,6 +3,26 @@
|
|||
* Reusable logic extracted from components
|
||||
*/
|
||||
|
||||
// Visible hours and time indicator
|
||||
export { useVisibleHours, useCurrentTimeIndicator } from './useVisibleHours.svelte';
|
||||
|
||||
// Event drag/drop and resize (comprehensive composable)
|
||||
export {
|
||||
useEventDragDrop,
|
||||
type EventDragDropConfig,
|
||||
type EventDragState,
|
||||
type EventResizeState,
|
||||
} from './useEventDragDrop.svelte';
|
||||
|
||||
// Task drag/drop and resize
|
||||
export { useTaskDragDrop, type TaskDragDropConfig } from './useTaskDragDrop.svelte';
|
||||
|
||||
// Sidebar task drop handling
|
||||
export { useSidebarDrop, type SidebarDropConfig } from './useSidebarDrop.svelte';
|
||||
|
||||
// Keyboard handling
|
||||
export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte';
|
||||
|
||||
// Legacy exports (kept for backwards compatibility, may be removed later)
|
||||
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
|
||||
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';
|
||||
export { useTaskDragDrop } from './useTaskDragDrop.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Calendar Keyboard Handling Composable
|
||||
* Handles keyboard shortcuts for calendar views (e.g., Escape to cancel drag/resize)
|
||||
*/
|
||||
|
||||
export interface CancellableOperation {
|
||||
/** Check if operation is active */
|
||||
isActive: () => boolean;
|
||||
/** Cancel the operation */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a keyboard handler that cancels operations on Escape key
|
||||
* Automatically sets up and cleans up the event listener via $effect
|
||||
*
|
||||
* @param operations - Array of operations that can be cancelled (e.g., drag/drop, resize)
|
||||
*/
|
||||
export function useCalendarKeyboard(operations: CancellableOperation[]) {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
// Check if any operation is active
|
||||
const activeOperation = operations.find((op) => op.isActive());
|
||||
if (activeOperation) {
|
||||
e.preventDefault();
|
||||
activeOperation.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup listener - call this in $effect
|
||||
function setup() {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return {
|
||||
setup,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Event Drag & Drop + Resize Composable
|
||||
* Extracts duplicated drag/resize logic from WeekView, DayView, MultiDayView
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
|
||||
export interface EventDragDropConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** Array of visible days (for multi-day views) or single day (for day view) */
|
||||
days: Date[];
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Last visible hour (for filtered hours mode) */
|
||||
lastVisibleHour: number;
|
||||
/** Total visible hours */
|
||||
totalVisibleHours: number;
|
||||
/** Height of one hour in pixels */
|
||||
hourHeight: number;
|
||||
/** Minutes per snap interval (default: 15) */
|
||||
snapMinutes?: number;
|
||||
/** Function to convert minutes to percentage position */
|
||||
minutesToPercent: (minutes: number) => number;
|
||||
}
|
||||
|
||||
export interface EventDragState {
|
||||
isDragging: boolean;
|
||||
draggedEvent: CalendarEvent | null;
|
||||
dragTargetDay: Date | null;
|
||||
dragPreviewTop: number;
|
||||
dragPreviewHeight: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export interface EventResizeState {
|
||||
isResizing: boolean;
|
||||
resizeEvent: CalendarEvent | null;
|
||||
resizeEdge: 'top' | 'bottom';
|
||||
resizePreviewTop: number;
|
||||
resizePreviewHeight: number;
|
||||
}
|
||||
|
||||
export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
|
||||
// ========== Drag State ==========
|
||||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
|
||||
// ========== Resize State ==========
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<CalendarEvent | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeOriginalStart = $state<Date | null>(null);
|
||||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let resizeOffsetMinutes = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function getSnapMinutes(): number {
|
||||
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
function snapToGrid(minutes: number): number {
|
||||
const snap = getSnapMinutes();
|
||||
return Math.round(minutes / snap) * snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day from X coordinate (for multi-day views)
|
||||
*/
|
||||
function getDayFromX(clientX: number): Date | null {
|
||||
const config = getConfig();
|
||||
if (!config.containerEl) return null;
|
||||
|
||||
const rect = config.containerEl.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
const dayWidth = rect.width / config.days.length;
|
||||
const dayIndex = Math.floor(relativeX / dayWidth);
|
||||
|
||||
if (dayIndex >= 0 && dayIndex < config.days.length) {
|
||||
return config.days[dayIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minutes from Y coordinate
|
||||
*/
|
||||
function getMinutesFromY(clientY: number): number {
|
||||
const config = getConfig();
|
||||
if (!config.containerEl) return 0;
|
||||
|
||||
const rect = config.containerEl.getBoundingClientRect();
|
||||
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
|
||||
const relativeY = clientY - rect.top + scrollTop;
|
||||
|
||||
// Account for hidden early hours
|
||||
const visibleMinutes =
|
||||
(relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60;
|
||||
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
|
||||
|
||||
// Snap to interval
|
||||
return snapToGrid(totalMinutes);
|
||||
}
|
||||
|
||||
// ========== Drag Functions ==========
|
||||
|
||||
function startDrag(event: CalendarEvent, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
isDragging = true;
|
||||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate initial preview position
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
dragPreviewTop = config.minutesToPercent(startMinutes);
|
||||
dragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
dragTargetDay = start;
|
||||
|
||||
// Calculate offset from event start to click position
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
dragOffsetMinutes = clickMinutes - startMinutes;
|
||||
|
||||
document.addEventListener('pointermove', handleDragMove);
|
||||
document.addEventListener('pointerup', handleDragEnd);
|
||||
}
|
||||
|
||||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !draggedEvent) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
// Calculate new position
|
||||
const newDay = getDayFromX(e.clientX);
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
|
||||
// Clamp to valid range
|
||||
const clampedMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(config.lastVisibleHour * 60 - 15, newMinutes)
|
||||
);
|
||||
|
||||
// Update preview
|
||||
dragPreviewTop = config.minutesToPercent(clampedMinutes);
|
||||
if (newDay) {
|
||||
dragTargetDay = newDay;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
|
||||
cleanupDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const start = toDate(draggedEvent.startTime);
|
||||
const end = toDate(draggedEvent.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate new start time
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
|
||||
const newHours = Math.floor(clampedMinutes / 60);
|
||||
const newMins = clampedMinutes % 60;
|
||||
|
||||
let newStart = new Date(dragTargetDay);
|
||||
newStart = setHours(newStart, newHours);
|
||||
newStart = setMinutes(newStart, newMins);
|
||||
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
// Update event via store
|
||||
if (eventsStore.isDraftEvent(draggedEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanupDrag();
|
||||
}
|
||||
|
||||
function cleanupDrag() {
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Resize Functions ==========
|
||||
|
||||
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
isResizing = true;
|
||||
resizeEvent = event;
|
||||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
||||
// Set initial preview
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
resizePreviewTop = config.minutesToPercent(startMinutes);
|
||||
resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Calculate offset between snapped click position and actual event boundary
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
if (edge === 'top') {
|
||||
resizeOffsetMinutes = clickMinutes - startMinutes;
|
||||
} else {
|
||||
resizeOffsetMinutes = clickMinutes - endMinutes;
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
||||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping when drag starts
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
// Resize from bottom - change end time
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
|
||||
} else {
|
||||
// Resize from top - change start time
|
||||
const newStartMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newDuration = originalEndMinutes - newStartMinutes;
|
||||
resizePreviewTop = config.minutesToPercent(newStartMinutes);
|
||||
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResizeEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
|
||||
cleanupResize();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
let newStart = resizeOriginalStart;
|
||||
let newEnd = resizeOriginalEnd;
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newEndMinutes / 60);
|
||||
const newMins = newEndMinutes % 60;
|
||||
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
|
||||
newEnd = setMinutes(newEnd, newMins);
|
||||
} else {
|
||||
const newStartMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newStartMinutes / 60);
|
||||
const newMins = newStartMinutes % 60;
|
||||
newStart = setHours(new Date(resizeOriginalStart), newHours);
|
||||
newStart = setMinutes(newStart, newMins);
|
||||
}
|
||||
|
||||
// Update event via store
|
||||
if (eventsStore.isDraftEvent(resizeEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
function cleanupResize() {
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Combined Cleanup ==========
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
cleanupDrag();
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any active drag/resize operation (e.g., on Escape key)
|
||||
*/
|
||||
function cancel() {
|
||||
if (isDragging || isResizing) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Drag state (reactive getters)
|
||||
get isDragging() {
|
||||
return isDragging;
|
||||
},
|
||||
get draggedEvent() {
|
||||
return draggedEvent;
|
||||
},
|
||||
get dragTargetDay() {
|
||||
return dragTargetDay;
|
||||
},
|
||||
get dragPreviewTop() {
|
||||
return dragPreviewTop;
|
||||
},
|
||||
get dragPreviewHeight() {
|
||||
return dragPreviewHeight;
|
||||
},
|
||||
|
||||
// Resize state (reactive getters)
|
||||
get isResizing() {
|
||||
return isResizing;
|
||||
},
|
||||
get resizeEvent() {
|
||||
return resizeEvent;
|
||||
},
|
||||
get resizeEdge() {
|
||||
return resizeEdge;
|
||||
},
|
||||
get resizePreviewTop() {
|
||||
return resizePreviewTop;
|
||||
},
|
||||
get resizePreviewHeight() {
|
||||
return resizePreviewHeight;
|
||||
},
|
||||
|
||||
// Shared state
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Reset hasMoved after click handling
|
||||
resetHasMoved() {
|
||||
hasMoved = false;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
startResize,
|
||||
cancel,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Sidebar Task Drop Composable
|
||||
* Handles dropping tasks from sidebar into calendar day columns
|
||||
*/
|
||||
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
|
||||
export interface SidebarDropConfig {
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Total visible hours */
|
||||
totalVisibleHours: number;
|
||||
/** Minutes per snap interval (default: 15) */
|
||||
snapMinutes?: number;
|
||||
}
|
||||
|
||||
export function useSidebarDrop(getConfig: () => SidebarDropConfig) {
|
||||
// Track active drop target (for visual feedback)
|
||||
let dropTarget = $state<{ day: Date; y: number } | null>(null);
|
||||
|
||||
function getSnapMinutes(): number {
|
||||
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
function formatTime(hours: number, minutes: number): string {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragover event on a day column
|
||||
*/
|
||||
function handleDragOver(e: DragEvent, day: Date) {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
// Check if this is a sidebar task drag
|
||||
const types = e.dataTransfer.types;
|
||||
if (!types.includes('application/json')) return;
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
dropTarget = { day, y: e.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragleave event
|
||||
*/
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
// Only clear if leaving the column entirely
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget?.closest('.day-column')) {
|
||||
dropTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drop event on a day column
|
||||
*/
|
||||
async function handleDrop(e: DragEvent, day: Date) {
|
||||
e.preventDefault();
|
||||
dropTarget = null;
|
||||
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
const jsonData = e.dataTransfer.getData('application/json');
|
||||
if (!jsonData) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
if (data.type !== 'sidebar-task') return;
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Calculate drop time from Y position
|
||||
const dayColumn = (e.target as HTMLElement).closest('.day-column');
|
||||
if (!dayColumn) return;
|
||||
|
||||
const rect = dayColumn.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snapMinutes = getSnapMinutes();
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes;
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const startTime = formatTime(hours, minutes);
|
||||
|
||||
// Calculate end time
|
||||
const duration = data.estimatedDuration || 30;
|
||||
const endMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = endMinutes % 60;
|
||||
const endTime = formatTime(endHours, endMins);
|
||||
|
||||
// Update the task with scheduled time
|
||||
await todosStore.updateTodo(data.taskId, {
|
||||
scheduledDate: format(day, 'yyyy-MM-dd'),
|
||||
scheduledStartTime: startTime,
|
||||
scheduledEndTime: endTime,
|
||||
estimatedDuration: duration,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to parse drop data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear drop target (use when component unmounts or for manual cleanup)
|
||||
*/
|
||||
function clearDropTarget() {
|
||||
dropTarget = null;
|
||||
}
|
||||
|
||||
return {
|
||||
// State (reactive getter)
|
||||
get dropTarget() {
|
||||
return dropTarget;
|
||||
},
|
||||
|
||||
// Methods
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearDropTarget,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,306 +1,321 @@
|
|||
/**
|
||||
* Composable for Task Drag & Drop in Calendar Views
|
||||
* Handles dragging tasks to reschedule and resizing to change duration
|
||||
* Task Drag & Drop + Resize Composable
|
||||
* Extracts duplicated task drag/resize logic from WeekView, DayView, MultiDayView
|
||||
*
|
||||
* Uses document-level event listeners for smooth drag operations across the entire screen.
|
||||
*/
|
||||
|
||||
import type { Task, UpdateTaskInput } from '$lib/api/todos';
|
||||
import type { Task } from '$lib/stores/todos.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
|
||||
const SNAP_MINUTES = 15;
|
||||
|
||||
interface UseTaskDragDropOptions {
|
||||
/** Minimum snap interval in minutes */
|
||||
export interface TaskDragDropConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** Array of visible days (for multi-day views) or single day (for day view) */
|
||||
days: Date[];
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Total visible hours */
|
||||
totalVisibleHours: number;
|
||||
/** Minutes per snap interval (default: 15) */
|
||||
snapMinutes?: number;
|
||||
/** Callback when task is updated */
|
||||
onTaskUpdate?: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) {
|
||||
const snapMinutes = options.snapMinutes ?? SNAP_MINUTES;
|
||||
|
||||
// Drag state
|
||||
let isDragging = $state(false);
|
||||
export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) {
|
||||
// ========== Drag State ==========
|
||||
let isTaskDragging = $state(false);
|
||||
let draggedTask = $state<Task | null>(null);
|
||||
let dragStartY = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
let taskDragTargetDay = $state<Date | null>(null);
|
||||
let taskDragPreviewTop = $state(0);
|
||||
let taskDragPreviewHeight = $state(0);
|
||||
|
||||
// Resize state
|
||||
let isResizing = $state(false);
|
||||
// ========== Resize State ==========
|
||||
let isTaskResizing = $state(false);
|
||||
let resizeTask = $state<Task | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeStartY = $state(0);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let taskResizePreviewTop = $state(0);
|
||||
let taskResizePreviewHeight = $state(0);
|
||||
|
||||
// Track if we actually moved
|
||||
// Track if we actually moved during drag/resize
|
||||
let hasMoved = $state(false);
|
||||
|
||||
/**
|
||||
* Start dragging a task
|
||||
*/
|
||||
function startDrag(
|
||||
task: Task,
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function getSnapMinutes(): number {
|
||||
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
function formatTime(hours: number, minutes: number): string {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ========== Drag Functions ==========
|
||||
|
||||
function startDrag(task: Task, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
|
||||
const config = getConfig();
|
||||
isTaskDragging = true;
|
||||
draggedTask = task;
|
||||
dragStartY = e.clientY;
|
||||
hasMoved = false;
|
||||
|
||||
// Calculate initial position
|
||||
// Initialize preview position from task's current time
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
const startMinutes = h * 60 + m - config.firstVisibleHour * 60;
|
||||
taskDragPreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
// Calculate height from duration
|
||||
const duration = task.estimatedDuration || 30;
|
||||
dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
taskDragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Capture pointer
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
document.addEventListener('pointermove', handleDragMove);
|
||||
document.addEventListener('pointerup', handleDragEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag move
|
||||
*/
|
||||
function onDragMove(
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
day: Date,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
if (!isDragging || !draggedTask) return;
|
||||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isTaskDragging || !draggedTask) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
dragTargetDay = day;
|
||||
|
||||
const rect = gridElement.getBoundingClientRect();
|
||||
// Find which day column we're over
|
||||
if (config.containerEl) {
|
||||
const dayColumns = config.containerEl.querySelectorAll('.day-column');
|
||||
for (let i = 0; i < dayColumns.length; i++) {
|
||||
const col = dayColumns[i];
|
||||
const rect = col.getBoundingClientRect();
|
||||
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||
taskDragTargetDay = config.days[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate vertical position
|
||||
const targetColumn = config.containerEl?.querySelector('.day-column');
|
||||
if (!targetColumn) return;
|
||||
|
||||
const rect = targetColumn.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = (relativeY / rect.height) * 100;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
// Snap to intervals
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60;
|
||||
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snapMinutes = getSnapMinutes();
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
|
||||
dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100;
|
||||
taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* End drag and update task
|
||||
*/
|
||||
async function endDrag(firstVisibleHour: number, totalVisibleHours: number) {
|
||||
if (!isDragging || !draggedTask || !hasMoved) {
|
||||
isDragging = false;
|
||||
draggedTask = null;
|
||||
dragTargetDay = null;
|
||||
async function handleDragEnd() {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||
cleanupDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new time from position
|
||||
const minutesFromMidnight =
|
||||
(dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
const hours = Math.floor(minutesFromMidnight / 60);
|
||||
const minutes = Math.round(minutesFromMidnight % 60);
|
||||
const config = getConfig();
|
||||
|
||||
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
// Calculate new time from position
|
||||
const minutesFromStart = (taskDragPreviewTop / 100) * (config.totalVisibleHours * 60);
|
||||
const totalMinutes = config.firstVisibleHour * 60 + minutesFromStart;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes % 60);
|
||||
|
||||
const newStartTime = formatTime(hours, minutes);
|
||||
|
||||
// Calculate end time based on duration
|
||||
const duration = draggedTask.estimatedDuration || 30;
|
||||
const endMinutes = minutesFromMidnight + duration;
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = Math.round(endMinutes % 60);
|
||||
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
const endTotalMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endTotalMinutes / 60);
|
||||
const endMins = Math.round(endTotalMinutes % 60);
|
||||
const newEndTime = formatTime(endHours, endMins);
|
||||
|
||||
const updateData: UpdateTaskInput = {
|
||||
scheduledDate: dragTargetDay
|
||||
? format(dragTargetDay, 'yyyy-MM-dd')
|
||||
: draggedTask.scheduledDate,
|
||||
await todosStore.updateTodo(draggedTask.id, {
|
||||
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await todosStore.updateTodo(draggedTask.id, updateData);
|
||||
if (result.data) {
|
||||
options.onTaskUpdate?.(result.data);
|
||||
}
|
||||
cleanupDrag();
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
function cleanupDrag() {
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
dragTargetDay = null;
|
||||
taskDragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start resizing a task
|
||||
*/
|
||||
function startResize(
|
||||
task: Task,
|
||||
edge: 'top' | 'bottom',
|
||||
e: PointerEvent,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
// ========== Resize Functions ==========
|
||||
|
||||
function startResize(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing = true;
|
||||
|
||||
const config = getConfig();
|
||||
isTaskResizing = true;
|
||||
resizeTask = task;
|
||||
resizeEdge = edge;
|
||||
resizeStartY = e.clientY;
|
||||
taskResizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
// Initialize preview position
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
const startMinutes = h * 60 + m - config.firstVisibleHour * 60;
|
||||
taskResizePreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resize move
|
||||
*/
|
||||
function onResizeMove(
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
if (!isResizing || !resizeTask) return;
|
||||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isTaskResizing || !resizeTask) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
const rect = gridElement.getBoundingClientRect();
|
||||
const targetColumn = config.containerEl?.querySelector('.day-column');
|
||||
if (!targetColumn) return;
|
||||
|
||||
const rect = targetColumn.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
|
||||
const snapMinutes = getSnapMinutes();
|
||||
|
||||
if (resizeEdge === 'top') {
|
||||
if (taskResizeEdge === 'top') {
|
||||
// Adjust start time, keep end fixed
|
||||
const originalEndPercent = resizePreviewTop + resizePreviewHeight;
|
||||
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop);
|
||||
taskResizePreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
|
||||
} else {
|
||||
// Adjust end time, keep start fixed
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop);
|
||||
const newBottom = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End resize and update task
|
||||
*/
|
||||
async function endResize(firstVisibleHour: number, totalVisibleHours: number) {
|
||||
if (!isResizing || !resizeTask || !hasMoved) {
|
||||
isResizing = false;
|
||||
resizeTask = null;
|
||||
async function handleResizeEnd() {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||
cleanupResize();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Calculate new times from position
|
||||
const startMinutes =
|
||||
(resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
(taskResizePreviewTop / 100) * (config.totalVisibleHours * 60) + config.firstVisibleHour * 60;
|
||||
const endMinutes =
|
||||
((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||
firstVisibleHour * 60;
|
||||
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (config.totalVisibleHours * 60) +
|
||||
config.firstVisibleHour * 60;
|
||||
|
||||
const startHours = Math.floor(startMinutes / 60);
|
||||
const startMins = Math.round(startMinutes % 60);
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = Math.round(endMinutes % 60);
|
||||
|
||||
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
|
||||
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
const newStartTime = formatTime(startHours, startMins);
|
||||
const newEndTime = formatTime(endHours, endMins);
|
||||
const newDuration = Math.round(endMinutes - startMinutes);
|
||||
|
||||
const updateData: UpdateTaskInput = {
|
||||
await todosStore.updateTodo(resizeTask.id, {
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
estimatedDuration: newDuration,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await todosStore.updateTodo(resizeTask.id, updateData);
|
||||
if (result.data) {
|
||||
options.onTaskUpdate?.(result.data);
|
||||
}
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
isResizing = false;
|
||||
function cleanupResize() {
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Combined Cleanup ==========
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
cleanupDrag();
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any ongoing drag/resize
|
||||
* Cancel any active drag/resize operation
|
||||
*/
|
||||
function cancel() {
|
||||
isDragging = false;
|
||||
isResizing = false;
|
||||
draggedTask = null;
|
||||
resizeTask = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
if (isTaskDragging || isTaskResizing) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State getters
|
||||
get isDragging() {
|
||||
return isDragging;
|
||||
// Drag state (reactive getters)
|
||||
get isTaskDragging() {
|
||||
return isTaskDragging;
|
||||
},
|
||||
get draggedTask() {
|
||||
return draggedTask;
|
||||
},
|
||||
get dragTargetDay() {
|
||||
return dragTargetDay;
|
||||
get taskDragTargetDay() {
|
||||
return taskDragTargetDay;
|
||||
},
|
||||
get dragPreviewTop() {
|
||||
return dragPreviewTop;
|
||||
get taskDragPreviewTop() {
|
||||
return taskDragPreviewTop;
|
||||
},
|
||||
get dragPreviewHeight() {
|
||||
return dragPreviewHeight;
|
||||
get taskDragPreviewHeight() {
|
||||
return taskDragPreviewHeight;
|
||||
},
|
||||
get isResizing() {
|
||||
return isResizing;
|
||||
|
||||
// Resize state (reactive getters)
|
||||
get isTaskResizing() {
|
||||
return isTaskResizing;
|
||||
},
|
||||
get resizeTask() {
|
||||
return resizeTask;
|
||||
},
|
||||
get resizePreviewTop() {
|
||||
return resizePreviewTop;
|
||||
get taskResizeEdge() {
|
||||
return taskResizeEdge;
|
||||
},
|
||||
get resizePreviewHeight() {
|
||||
return resizePreviewHeight;
|
||||
get taskResizePreviewTop() {
|
||||
return taskResizePreviewTop;
|
||||
},
|
||||
get taskResizePreviewHeight() {
|
||||
return taskResizePreviewHeight;
|
||||
},
|
||||
|
||||
// Shared state
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
onDragMove,
|
||||
endDrag,
|
||||
startResize,
|
||||
onResizeMove,
|
||||
endResize,
|
||||
cancel,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue