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}`;
}
}

View file

@ -6,20 +6,7 @@ import { DateCalculator } from '../utils/DateCalculator';
import { eventBus } from '../core/EventBus'; import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents'; import { CoreEvents } from '../constants/CoreEvents';
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector'; import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
import { ResizeManager } from '../managers/ResizeManager';
/**
* Resize state interface
*/
interface ResizeState {
element: HTMLElement;
handle: 'top' | 'bottom';
startY: number;
originalTop: number;
originalHeight: number;
originalStartTime: Date;
originalEndTime: Date;
minHeightPx: number;
}
/** /**
* Interface for event rendering strategies * Interface for event rendering strategies
@ -39,15 +26,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
private draggedClone: HTMLElement | null = null; private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null; private originalEvent: HTMLElement | null = null;
// Resize state // Resize manager
private resizeState: ResizeState | null = null; private resizeManager: ResizeManager;
private readonly MIN_EVENT_DURATION_MINUTES = 30;
constructor(dateCalculator?: DateCalculator) { constructor(dateCalculator?: DateCalculator) {
if (!dateCalculator) { if (!dateCalculator) {
DateCalculator.initialize(calendarConfig); DateCalculator.initialize(calendarConfig);
} }
this.dateCalculator = dateCalculator || new DateCalculator(); this.dateCalculator = dateCalculator || new DateCalculator();
this.resizeManager = new ResizeManager(eventBus);
} }
// ============================================ // ============================================
@ -259,23 +246,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
} }
} }
/**
* Calculate event duration in minutes from element height
*/
private getEventDuration(element: HTMLElement): number {
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
// Get height from style or computed
let heightPx = parseInt(element.style.height) || 0;
if (!heightPx) {
const rect = element.getBoundingClientRect();
heightPx = rect.height;
}
return Math.round((heightPx / hourHeight) * 60);
}
/** /**
* Unified time formatting method - handles both total minutes and Date objects * Unified time formatting method - handles both total minutes and Date objects
*/ */
@ -378,7 +348,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
if (originalStackLink) { if (originalStackLink) {
try { try {
const stackData = JSON.parse(originalStackLink); const stackData = JSON.parse(originalStackLink);
const stackEventIds: string[] = [];
// Saml ALLE event IDs fra hele stack chain // Saml ALLE event IDs fra hele stack chain
const allStackEventIds: Set<string> = new Set(); const allStackEventIds: Set<string> = new Set();
@ -510,46 +479,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
}); });
} }
/**
* Handle event double-click for text selection
*/
private handleEventDoubleClick(eventElement: HTMLElement): void {
console.log('handleEventDoubleClick:', eventElement.dataset.eventId);
// Enable text selection temporarily
eventElement.classList.add('text-selectable');
// Auto-select the event text
const selection = window.getSelection();
if (selection) {
const range = document.createRange();
range.selectNodeContents(eventElement);
selection.removeAllRanges();
selection.addRange(range);
}
// Remove text selection mode when clicking outside
const removeSelectable = (e: Event) => {
// Don't remove if clicking within the same event
if (e.target && eventElement.contains(e.target as Node)) {
return;
}
eventElement.classList.remove('text-selectable');
document.removeEventListener('click', removeSelectable);
// Clear selection
if (selection) {
selection.removeAllRanges();
}
};
// Add click outside listener after a short delay
setTimeout(() => {
document.addEventListener('click', removeSelectable);
}, 100);
}
/** /**
* Handle overlap detection and re-rendering after drag-drop * Handle overlap detection and re-rendering after drag-drop
*/ */
@ -622,19 +551,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// With the new system, overlap relationships are recalculated on drop // With the new system, overlap relationships are recalculated on drop
// No need to manually track and remove from groups // No need to manually track and remove from groups
} }
/**
* Restore normal event styling (full column width)
*/
private restoreNormalEventStyling(eventElement: HTMLElement): void {
eventElement.style.position = 'absolute';
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.width = '';
// Behold z-index for stacked events
}
/** /**
* Update element's dataset with new times after successful drop * Update element's dataset with new times after successful drop
@ -1035,17 +951,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// Setup resize handles on first mouseover only // Setup resize handles on first mouseover only
eventElement.addEventListener('mouseover', () => { eventElement.addEventListener('mouseover', () => {
if (eventElement.dataset.hasResizeHandlers !== 'true') { if (eventElement.dataset.hasResizeHandlers !== 'true') {
this.setupDynamicResizeHandles(eventElement); this.resizeManager.setupResizeHandles(eventElement);
eventElement.dataset.hasResizeHandlers = 'true'; eventElement.dataset.hasResizeHandlers = 'true';
} }
}, { once: true }); }, { once: true });
// Setup double-click for text selection
eventElement.addEventListener('dblclick', (e) => {
e.stopPropagation();
this.handleEventDoubleClick(eventElement);
});
return eventElement; return eventElement;
} }
@ -1115,220 +1025,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
/**
* Setup dynamic resize handles that are only created when needed
*/
private setupDynamicResizeHandles(eventElement: HTMLElement): void {
let topHandle: HTMLElement | null = null;
let bottomHandle: HTMLElement | null = null;
console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId);
// Create handles on mouse enter
eventElement.addEventListener('mouseenter', () => {
console.log('Mouse ENTER event:', eventElement.dataset.eventId);
// Only create if they don't already exist
if (!topHandle || !bottomHandle) {
topHandle = document.createElement('swp-resize-handle');
topHandle.setAttribute('data-position', 'top');
topHandle.style.opacity = '0';
bottomHandle = document.createElement('swp-resize-handle');
bottomHandle.setAttribute('data-position', 'bottom');
bottomHandle.style.opacity = '0';
// Add mousedown listeners for resize functionality
topHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.stopPropagation(); // Forhindre normal drag
e.preventDefault();
this.startResize(eventElement, 'top', e);
});
bottomHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.stopPropagation(); // Forhindre normal drag
e.preventDefault();
this.startResize(eventElement, 'bottom', e);
});
// Insert handles at beginning and end
eventElement.insertBefore(topHandle, eventElement.firstChild);
eventElement.appendChild(bottomHandle);
console.log('Created resize handles for event:', eventElement.dataset.eventId);
}
});
// Mouse move handler for smart visibility
eventElement.addEventListener('mousemove', (e: MouseEvent) => {
if (!topHandle || !bottomHandle) return;
const rect = eventElement.getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
// Show top handle if mouse is in top 12px
if (y <= 12) {
topHandle.style.opacity = '1';
bottomHandle.style.opacity = '0';
}
// Show bottom handle if mouse is in bottom 12px
else if (y >= height - 12) {
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 (men kun hvis ikke i 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 fra toppen
const newTop = this.resizeState.originalTop + snappedDelta;
const newHeight = this.resizeState.originalHeight - snappedDelta;
// Check minimum højde
if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) {
this.resizeState.element.style.top = newTop + 'px';
this.resizeState.element.style.height = newHeight + 'px';
// Opdater tidspunkter
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 fra bunden
const newHeight = this.resizeState.originalHeight + snappedDelta;
// Check minimum højde
if (newHeight >= this.resizeState.minHeightPx) {
this.resizeState.element.style.height = newHeight + 'px';
// Opdater tidspunkter
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;
// Få finale tider fra 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 med nye tider
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 {
// Beregn ny duration i minutter
const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
// Opdater dataset
element.dataset.start = startTime.toISOString();
element.dataset.end = endTime.toISOString();
element.dataset.duration = durationMinutes.toString();
// Opdater visual tid
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}`;
// Opdater også data-duration attribut på time elementet
timeElement.setAttribute('data-duration', durationMinutes.toString());
}
}
/**
* Add minutes to a date
*/
private addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60000);
}
clearEvents(container?: HTMLElement): void { clearEvents(container?: HTMLElement): void {
const selector = 'swp-event, swp-event-group'; const selector = 'swp-event, swp-event-group';