Calendar/src/managers/ResizeHandleManager.ts
Janus C. H. Knudsen ccfc1a99b2 Refactor ResizeHandleManager with improved resize logic
Enhances event resizing functionality with smoother animation and more robust handling

Removes complex resize zone tracking in favor of simplified resize mechanism
Improves performance and simplifies event resize interactions
Cleans up unnecessary complexity in pointer and animation management
2025-11-06 22:14:35 +01:00

266 lines
No EOL
7.9 KiB
TypeScript

import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { Configuration } from '../configurations/CalendarConfig';
import { IResizeEndEventPayload } from '../types/EventTypes';
import { PositionUtils } from '../utils/PositionUtils';
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
export class ResizeHandleManager {
private cachedEvents: SwpEventEl[] = [];
private isResizing = false;
private targetEl: SwpEventEl | null = null;
private startY = 0;
private startDurationMin = 0;
private direction: 'grow' | 'shrink' = 'grow';
private snapMin: number;
private minDurationMin: number;
private animationId: number | null = null;
private currentHeight = 0;
private targetHeight = 0;
private unsubscribers: Array<() => void> = [];
private pointerCaptured = false;
private prevZ?: string;
// Constants for better maintainability
private readonly ANIMATION_SPEED = 0.35;
private readonly Z_INDEX_RESIZING = '1000';
private readonly EVENT_REFRESH_THRESHOLD = 0.5;
constructor(
private config: Configuration,
private positionUtils: PositionUtils
) {
const grid = this.config.gridSettings;
this.snapMin = grid.snapInterval;
this.minDurationMin = this.snapMin;
}
public initialize(): void {
this.refreshEventCache();
this.attachHandles();
this.attachGlobalListeners();
this.subscribeToEventBus();
}
public destroy(): void {
this.removeEventListeners();
this.unsubscribers.forEach(unsubscribe => unsubscribe());
this.unsubscribers = [];
}
private removeEventListeners(): void {
document.removeEventListener('pointerdown', this.onPointerDown, true);
document.removeEventListener('pointermove', this.onPointerMove, true);
document.removeEventListener('pointerup', this.onPointerUp, true);
}
private refreshEventCache(): void {
this.cachedEvents = Array.from(
document.querySelectorAll<SwpEventEl>('swp-day-columns swp-event')
);
}
private attachHandles(): void {
this.cachedEvents.forEach(element => {
if (!element.querySelector(':scope > swp-resize-handle')) {
const handle = this.createResizeHandle();
element.appendChild(handle);
}
});
}
private createResizeHandle(): HTMLElement {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
return handle;
}
private attachGlobalListeners(): void {
document.addEventListener('pointerdown', this.onPointerDown, true);
document.addEventListener('pointermove', this.onPointerMove, true);
document.addEventListener('pointerup', this.onPointerUp, true);
}
private subscribeToEventBus(): void {
const eventsToRefresh = [
CoreEvents.GRID_RENDERED,
CoreEvents.EVENTS_RENDERED,
CoreEvents.EVENT_CREATED,
CoreEvents.EVENT_UPDATED,
CoreEvents.EVENT_DELETED
];
const refresh = () => {
this.refreshEventCache();
this.attachHandles();
};
eventsToRefresh.forEach(event => {
eventBus.on(event, refresh);
this.unsubscribers.push(() => eventBus.off(event, refresh));
});
}
private onPointerDown = (e: PointerEvent): void => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
if (!handle) return;
const element = handle.parentElement as SwpEventEl;
this.startResizing(element, e);
};
private startResizing(element: SwpEventEl, event: PointerEvent): void {
this.targetEl = element;
this.isResizing = true;
this.startY = event.clientY;
const startHeight = element.offsetHeight;
this.startDurationMin = Math.max(
this.minDurationMin,
Math.round(this.positionUtils.pixelsToMinutes(startHeight))
);
this.setZIndexForResizing(element);
this.capturePointer(event);
document.documentElement.classList.add('swp--resizing');
event.preventDefault();
}
private setZIndexForResizing(element: SwpEventEl): void {
const container = element.closest<HTMLElement>('swp-event-group') ?? element;
this.prevZ = container.style.zIndex;
container.style.zIndex = this.Z_INDEX_RESIZING;
}
private capturePointer(event: PointerEvent): void {
try {
(event.target as Element).setPointerCapture?.(event.pointerId);
this.pointerCaptured = true;
} catch (error) {
console.warn('Pointer capture failed:', error);
}
}
private onPointerMove = (e: PointerEvent): void => {
if (!this.isResizing || !this.targetEl) return;
this.updateResizeHeight(e.clientY);
};
private updateResizeHeight(currentY: number): void {
const deltaY = currentY - this.startY;
this.direction = deltaY >= 0 ? 'grow' : 'shrink';
const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin);
const rawHeight = startHeight + deltaY;
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
this.targetHeight = Math.max(minHeight, rawHeight);
if (this.animationId == null) {
this.currentHeight = this.targetEl?.offsetHeight!!;
this.animate();
}
}
private animate = (): void => {
if (!this.isResizing || !this.targetEl) {
this.animationId = null;
return;
}
const diff = this.targetHeight - this.currentHeight;
if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) {
this.currentHeight += diff * this.ANIMATION_SPEED;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = requestAnimationFrame(this.animate);
} else {
this.finalizeAnimation();
}
};
private finalizeAnimation(): void {
if (!this.targetEl) return;
this.currentHeight = this.targetHeight;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = null;
}
private onPointerUp = (e: PointerEvent): void => {
if (!this.isResizing || !this.targetEl) return;
this.cleanupAnimation();
this.snapToGrid();
this.emitResizeEndEvent();
this.cleanupResizing(e);
};
private cleanupAnimation(): void {
if (this.animationId != null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
private snapToGrid(): void {
if (!this.targetEl) return;
const currentHeight = this.targetEl.offsetHeight;
const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin);
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines
this.targetEl.updateHeight?.(finalHeight);
}
private emitResizeEndEvent(): void {
if (!this.targetEl) return;
const eventId = this.targetEl.dataset.eventId || '';
const resizeEndPayload: IResizeEndEventPayload = {
eventId,
element: this.targetEl,
finalHeight: this.targetEl.offsetHeight
};
eventBus.emit('resize:end', resizeEndPayload);
}
private cleanupResizing(event: PointerEvent): void {
this.restoreZIndex();
this.releasePointer(event);
this.isResizing = false;
this.targetEl = null;
document.documentElement.classList.remove('swp--resizing');
this.refreshEventCache(); // TODO: Optimize to avoid full cache refresh
}
private restoreZIndex(): void {
if (!this.targetEl || this.prevZ === undefined) return;
const container = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
container.style.zIndex = this.prevZ;
this.prevZ = undefined;
}
private releasePointer(event: PointerEvent): void {
if (!this.pointerCaptured) return;
try {
(event.target as Element).releasePointerCapture?.(event.pointerId);
this.pointerCaptured = false;
} catch (error) {
console.warn('Pointer release failed:', error);
}
}
}