Adds event resizing functionality to calendar

Introduces ResizeManager to handle dynamic event duration updates

Implements smooth height animation and grid-based snapping
Adds resize event tracking and timestamp display during resizing
Integrates resize handle creation and pointer event management
This commit is contained in:
Janus C. H. Knudsen 2025-12-10 22:16:40 +01:00
parent cacd312936
commit 026d83eb32
6 changed files with 314 additions and 1 deletions

View file

@ -75,6 +75,10 @@ export class DragDropManager {
private handlePointerDown = (e: PointerEvent): void => {
const target = e.target as HTMLElement;
// Ignore if clicking on resize handle
if (target.closest('swp-resize-handle')) return;
const eventElement = target.closest('swp-event') as HTMLElement;
if (!eventElement) return;

View file

@ -0,0 +1,282 @@
import { IEventBus } from '../types/CalendarTypes';
import { IGridConfig } from '../core/IGridConfig';
import { pixelsToMinutes, minutesToPixels, snapToGrid } from '../utils/PositionUtils';
import { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IResizeStartPayload, IResizeEndPayload } from '../types/ResizeTypes';
/**
* ResizeManager - Handles resize of calendar events
*
* Step 1: Handle creation on mouseover (CSS handles visibility)
* Step 2: Pointer events + resize start
* Step 3: RAF animation for smooth height update
* Step 4: Grid snapping + timestamp update
*/
interface ResizeState {
eventId: string;
element: HTMLElement;
handleElement: HTMLElement;
startY: number;
startHeight: number;
startDurationMinutes: number;
pointerId: number;
prevZIndex: string;
// Animation state
currentHeight: number;
targetHeight: number;
animationId: number | null;
}
export class ResizeManager {
private container: HTMLElement | null = null;
private resizeState: ResizeState | null = null;
private readonly Z_INDEX_RESIZING = '1000';
private readonly ANIMATION_SPEED = 0.35;
private readonly MIN_HEIGHT_MINUTES = 15;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private dateService: DateService
) {}
/**
* Initialize resize functionality on container
*/
init(container: HTMLElement): void {
this.container = container;
// Mouseover listener for handle creation (capture phase like V1)
container.addEventListener('mouseover', this.handleMouseOver, true);
// Pointer listeners for resize (capture phase like V1)
document.addEventListener('pointerdown', this.handlePointerDown, true);
document.addEventListener('pointermove', this.handlePointerMove, true);
document.addEventListener('pointerup', this.handlePointerUp, true);
}
/**
* Create resize handle element
*/
private createResizeHandle(): HTMLElement {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
return handle;
}
/**
* Handle mouseover - create resize handle if not exists
*/
private handleMouseOver = (e: Event): void => {
const target = e.target as HTMLElement;
const eventElement = target.closest('swp-event') as HTMLElement;
if (!eventElement || this.resizeState) return;
// Check if handle already exists
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
const handle = this.createResizeHandle();
eventElement.appendChild(handle);
}
};
/**
* Handle pointerdown - start resize if on handle
*/
private handlePointerDown = (e: PointerEvent): void => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle') as HTMLElement;
if (!handle) return;
const element = handle.parentElement as HTMLElement;
if (!element) return;
const eventId = element.dataset.id || '';
const startHeight = element.offsetHeight;
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
// Store previous z-index
const container = element.closest('swp-event-group') as HTMLElement ?? element;
const prevZIndex = container.style.zIndex;
// Set resize state
this.resizeState = {
eventId,
element,
handleElement: handle,
startY: e.clientY,
startHeight,
startDurationMinutes,
pointerId: e.pointerId,
prevZIndex,
// Animation state
currentHeight: startHeight,
targetHeight: startHeight,
animationId: null
};
// Elevate z-index
container.style.zIndex = this.Z_INDEX_RESIZING;
// Capture pointer for smooth tracking
try {
handle.setPointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer capture failed:', err);
}
// Add global resizing class
document.documentElement.classList.add('swp--resizing');
// Emit resize start event
this.eventBus.emit(CoreEvents.EVENT_RESIZE_START, {
eventId,
element,
startHeight
} as IResizeStartPayload);
e.preventDefault();
};
/**
* Handle pointermove - update target height during resize
*/
private handlePointerMove = (e: PointerEvent): void => {
if (!this.resizeState) return;
const deltaY = e.clientY - this.resizeState.startY;
const minHeight = (this.MIN_HEIGHT_MINUTES / 60) * this.gridConfig.hourHeight;
const newHeight = Math.max(minHeight, this.resizeState.startHeight + deltaY);
// Set target height for animation
this.resizeState.targetHeight = newHeight;
// Start animation if not running
if (this.resizeState.animationId === null) {
this.animateHeight();
}
};
/**
* RAF animation loop for smooth height interpolation
*/
private animateHeight = (): void => {
if (!this.resizeState) return;
const diff = this.resizeState.targetHeight - this.resizeState.currentHeight;
// Stop animation when close enough
if (Math.abs(diff) < 0.5) {
this.resizeState.animationId = null;
return;
}
// Interpolate towards target (35% per frame like V1)
this.resizeState.currentHeight += diff * this.ANIMATION_SPEED;
this.resizeState.element.style.height = `${this.resizeState.currentHeight}px`;
// Update timestamp display (snapped)
this.updateTimestampDisplay();
// Continue animation
this.resizeState.animationId = requestAnimationFrame(this.animateHeight);
};
/**
* Update timestamp display with snapped end time
*/
private updateTimestampDisplay(): void {
if (!this.resizeState) return;
const timeEl = this.resizeState.element.querySelector('swp-event-time');
if (!timeEl) return;
// Get start time from element position
const top = parseFloat(this.resizeState.element.style.top) || 0;
const startMinutesFromGrid = pixelsToMinutes(top, this.gridConfig);
const startMinutes = (this.gridConfig.dayStartHour * 60) + startMinutesFromGrid;
// Calculate snapped end time from current height
const snappedHeight = snapToGrid(this.resizeState.currentHeight, this.gridConfig);
const durationMinutes = pixelsToMinutes(snappedHeight, this.gridConfig);
const endMinutes = startMinutes + durationMinutes;
// Format and update
const start = this.minutesToDate(startMinutes);
const end = this.minutesToDate(endMinutes);
timeEl.textContent = this.dateService.formatTimeRange(start, end);
}
/**
* Convert minutes since midnight to Date
*/
private minutesToDate(minutes: number): Date {
const date = new Date();
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
return date;
};
/**
* Handle pointerup - finish resize
*/
private handlePointerUp = (e: PointerEvent): void => {
if (!this.resizeState) return;
// Cancel any pending animation
if (this.resizeState.animationId !== null) {
cancelAnimationFrame(this.resizeState.animationId);
}
// Release pointer capture
try {
this.resizeState.handleElement.releasePointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer release failed:', err);
}
// Snap final height to grid
this.snapToGridFinal();
// Update timestamp one final time
this.updateTimestampDisplay();
// Restore z-index
const container = this.resizeState.element.closest('swp-event-group') as HTMLElement ?? this.resizeState.element;
container.style.zIndex = this.resizeState.prevZIndex;
// Remove global resizing class
document.documentElement.classList.remove('swp--resizing');
// Emit resize end event
const newHeight = this.resizeState.currentHeight;
const newDurationMinutes = pixelsToMinutes(newHeight, this.gridConfig);
this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, {
eventId: this.resizeState.eventId,
element: this.resizeState.element,
newHeight,
newDurationMinutes
} as IResizeEndPayload);
// Reset state
this.resizeState = null;
};
/**
* Snap final height to grid interval
*/
private snapToGridFinal(): void {
if (!this.resizeState) return;
const currentHeight = this.resizeState.element.offsetHeight;
const snappedHeight = snapToGrid(currentHeight, this.gridConfig);
const minHeight = minutesToPixels(this.MIN_HEIGHT_MINUTES, this.gridConfig);
const finalHeight = Math.max(minHeight, snappedHeight);
this.resizeState.element.style.height = `${finalHeight}px`;
this.resizeState.currentHeight = finalHeight;
}
}