Implements event resizing functionality

Introduces event resizing feature, allowing users to dynamically adjust event durations by dragging handles on the top or bottom of events.

Moves resize logic into a dedicated ResizeManager class for better code organization and separation of concerns.
This commit is contained in:
Janus Knudsen 2025-09-10 00:10:12 +02:00
parent 86fa7d5bab
commit d205ccb0b6
2 changed files with 270 additions and 309 deletions

View file

@ -0,0 +1,264 @@
import { calendarConfig } from '../core/CalendarConfig';
import { eventBus } from '../core/EventBus';
import { IEventBus } from '../types/CalendarTypes';
/**
* Resize state interface
*/
interface ResizeState {
element: HTMLElement;
handle: 'top' | 'bottom';
startY: number;
originalTop: number;
originalHeight: number;
originalStartTime: Date;
originalEndTime: Date;
minHeightPx: number;
}
/**
* ResizeManager - Handles event resizing functionality
*/
export class ResizeManager {
private resizeState: ResizeState | null = null;
private readonly MIN_EVENT_DURATION_MINUTES = 15;
constructor(private eventBus: IEventBus) {
// Bind methods for event listeners
this.handleResize = this.handleResize.bind(this);
this.endResize = this.endResize.bind(this);
}
/**
* Setup dynamic resize handles that are only created when needed
* @param eventElement - Event element to add resize handles to
*/
public setupResizeHandles(eventElement: HTMLElement): void {
// Variables to track resize handles
let topHandle: HTMLElement | null = null;
let bottomHandle: HTMLElement | null = null;
console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId);
// Create resize handles on first mouseover
eventElement.addEventListener('mouseenter', () => {
if (!topHandle && !bottomHandle) {
topHandle = document.createElement('swp-resize-handle');
topHandle.className = 'swp-resize-handle swp-resize-top';
bottomHandle = document.createElement('swp-resize-handle');
bottomHandle.className = 'swp-resize-handle swp-resize-bottom';
// Add mousedown listeners for resize functionality
topHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.startResize(eventElement, 'top', e);
});
bottomHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.startResize(eventElement, 'bottom', e);
});
eventElement.appendChild(topHandle);
eventElement.appendChild(bottomHandle);
console.log('Created resize handles for event:', eventElement.dataset.eventId);
}
});
// Show/hide handles based on mouse position
eventElement.addEventListener('mousemove', (e: MouseEvent) => {
if (!topHandle || !bottomHandle) return;
const rect = eventElement.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const eventHeight = rect.height;
const topZone = eventHeight * 0.2;
const bottomZone = eventHeight * 0.8;
// Show top handle in upper 20%
if (mouseY < topZone) {
topHandle.style.opacity = '1';
bottomHandle.style.opacity = '0';
}
// Show bottom handle in lower 20%
else if (mouseY > bottomZone) {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '1';
}
// Hide both if mouse is in middle
else {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '0';
}
});
// Hide handles when mouse leaves event (but only if not in resize mode)
eventElement.addEventListener('mouseleave', () => {
console.log('Mouse LEAVE event:', eventElement.dataset.eventId);
if (!this.resizeState && topHandle && bottomHandle) {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '0';
console.log('Hidden resize handles for event:', eventElement.dataset.eventId);
}
});
}
/**
* Start resize operation
*/
private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void {
const gridSettings = calendarConfig.getGridSettings();
const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight;
this.resizeState = {
element: eventElement,
handle: handle,
startY: e.clientY,
originalTop: parseFloat(eventElement.style.top),
originalHeight: parseFloat(eventElement.style.height),
originalStartTime: new Date(eventElement.dataset.start || ''),
originalEndTime: new Date(eventElement.dataset.end || ''),
minHeightPx: minHeightPx
};
// Global listeners for resize
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('mouseup', this.endResize);
// Add resize cursor to body
document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize';
console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId);
}
/**
* Handle resize drag
*/
private handleResize(e: MouseEvent): void {
if (!this.resizeState) return;
const deltaY = e.clientY - this.resizeState.startY;
const snappedDelta = this.snapToGrid(deltaY);
const gridSettings = calendarConfig.getGridSettings();
if (this.resizeState.handle === 'top') {
// Resize from top
const newTop = this.resizeState.originalTop + snappedDelta;
const newHeight = this.resizeState.originalHeight - snappedDelta;
// Check minimum height
if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) {
this.resizeState.element.style.top = newTop + 'px';
this.resizeState.element.style.height = newHeight + 'px';
// Update times
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta);
this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime);
}
} else {
// Resize from bottom
const newHeight = this.resizeState.originalHeight + snappedDelta;
// Check minimum height
if (newHeight >= this.resizeState.minHeightPx) {
this.resizeState.element.style.height = newHeight + 'px';
// Update times
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta);
this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime);
}
}
}
/**
* End resize operation
*/
private endResize(): void {
if (!this.resizeState) return;
// Get final times from element
const finalStart = this.resizeState.element.dataset.start;
const finalEnd = this.resizeState.element.dataset.end;
console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd);
// Emit event with new times
this.eventBus.emit('event:resized', {
eventId: this.resizeState.element.dataset.eventId,
newStart: finalStart,
newEnd: finalEnd
});
// Cleanup
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('mouseup', this.endResize);
document.body.style.cursor = '';
this.resizeState = null;
}
/**
* Snap delta to grid intervals
*/
private snapToGrid(deltaY: number): number {
const gridSettings = calendarConfig.getGridSettings();
const snapInterval = gridSettings.snapInterval;
const hourHeight = gridSettings.hourHeight;
const snapDistancePx = (snapInterval / 60) * hourHeight;
return Math.round(deltaY / snapDistancePx) * snapDistancePx;
}
/**
* Update event display during resize
*/
private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void {
// Calculate new duration in minutes
const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
// Update dataset
element.dataset.start = startTime.toISOString();
element.dataset.end = endTime.toISOString();
element.dataset.duration = durationMinutes.toString();
// Update visual time
const timeElement = element.querySelector('swp-event-time');
if (timeElement) {
const startStr = this.formatTime(startTime.toISOString());
const endStr = this.formatTime(endTime.toISOString());
timeElement.textContent = `${startStr} - ${endStr}`;
}
}
/**
* Add minutes to a date
*/
private addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60000);
}
/**
* Format time for display
*/
private formatTime(input: Date | string): string {
let hours: number;
let minutes: number;
if (input instanceof Date) {
hours = input.getHours();
minutes = input.getMinutes();
} else {
// Date or ISO string input
const date = typeof input === 'string' ? new Date(input) : input;
hours = date.getHours();
minutes = date.getMinutes();
}
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
}