2025-10-07 17:16:00 +02:00
|
|
|
import { eventBus } from '../core/EventBus';
|
|
|
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
2025-11-03 22:04:37 +01:00
|
|
|
import { Configuration } from '../configurations/CalendarConfig';
|
2025-11-03 21:30:50 +01:00
|
|
|
import { IResizeEndEventPayload } from '../types/EventTypes';
|
2025-11-05 21:53:08 +01:00
|
|
|
import { PositionUtils } from '../utils/PositionUtils';
|
2025-10-07 17:16:00 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
export class ResizeHandleManager {
|
|
|
|
|
private cachedEvents: SwpEventEl[] = [];
|
2025-10-08 00:58:38 +02:00
|
|
|
private isResizing = false;
|
2025-10-08 21:43:02 +02:00
|
|
|
private targetEl: SwpEventEl | null = null;
|
2025-11-06 22:14:35 +01:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private startY = 0;
|
|
|
|
|
private startDurationMin = 0;
|
|
|
|
|
private direction: 'grow' | 'shrink' = 'grow';
|
2025-11-06 22:14:35 +01:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private snapMin: number;
|
|
|
|
|
private minDurationMin: number;
|
|
|
|
|
private animationId: number | null = null;
|
|
|
|
|
private currentHeight = 0;
|
|
|
|
|
private targetHeight = 0;
|
2025-11-06 22:14:35 +01:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private unsubscribers: Array<() => void> = [];
|
|
|
|
|
private pointerCaptured = false;
|
|
|
|
|
private prevZ?: string;
|
2025-11-06 22:14:35 +01:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
) {
|
2025-11-03 22:04:37 +01:00
|
|
|
const grid = this.config.gridSettings;
|
2025-10-08 21:43:02 +02:00
|
|
|
this.snapMin = grid.snapInterval;
|
2025-11-06 22:14:35 +01:00
|
|
|
this.minDurationMin = this.snapMin;
|
2025-10-08 00:58:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-06 23:38:01 +02:00
|
|
|
public initialize(): void {
|
2025-10-07 17:16:00 +02:00
|
|
|
this.refreshEventCache();
|
2025-10-08 21:43:02 +02:00
|
|
|
this.attachHandles();
|
|
|
|
|
this.attachGlobalListeners();
|
2025-11-06 22:14:35 +01:00
|
|
|
this.subscribeToEventBus();
|
2025-10-08 21:43:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public destroy(): void {
|
2025-11-06 22:14:35 +01:00
|
|
|
this.removeEventListeners();
|
|
|
|
|
this.unsubscribers.forEach(unsubscribe => unsubscribe());
|
|
|
|
|
this.unsubscribers = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private removeEventListeners(): void {
|
2025-10-08 21:43:02 +02:00
|
|
|
document.removeEventListener('pointerdown', this.onPointerDown, true);
|
|
|
|
|
document.removeEventListener('pointermove', this.onPointerMove, true);
|
|
|
|
|
document.removeEventListener('pointerup', this.onPointerUp, true);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:16:00 +02:00
|
|
|
private refreshEventCache(): void {
|
|
|
|
|
this.cachedEvents = Array.from(
|
2025-10-08 21:43:02 +02:00
|
|
|
document.querySelectorAll<SwpEventEl>('swp-day-columns swp-event')
|
2025-10-07 17:16:00 +02:00
|
|
|
);
|
|
|
|
|
}
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private attachHandles(): void {
|
2025-11-06 22:14:35 +01:00
|
|
|
this.cachedEvents.forEach(element => {
|
|
|
|
|
if (!element.querySelector(':scope > swp-resize-handle')) {
|
|
|
|
|
const handle = this.createResizeHandle();
|
|
|
|
|
element.appendChild(handle);
|
2025-10-08 00:58:38 +02:00
|
|
|
}
|
2025-10-06 23:38:01 +02:00
|
|
|
});
|
2025-10-08 21:43:02 +02:00
|
|
|
}
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private createResizeHandle(): HTMLElement {
|
|
|
|
|
const handle = document.createElement('swp-resize-handle');
|
|
|
|
|
handle.setAttribute('aria-label', 'Resize event');
|
|
|
|
|
handle.setAttribute('role', 'separator');
|
|
|
|
|
return handle;
|
|
|
|
|
}
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private attachGlobalListeners(): void {
|
2025-10-08 21:43:02 +02:00
|
|
|
document.addEventListener('pointerdown', this.onPointerDown, true);
|
|
|
|
|
document.addEventListener('pointermove', this.onPointerMove, true);
|
|
|
|
|
document.addEventListener('pointerup', this.onPointerUp, true);
|
|
|
|
|
}
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
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();
|
2025-10-08 21:43:02 +02:00
|
|
|
};
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
eventsToRefresh.forEach(event => {
|
|
|
|
|
eventBus.on(event, refresh);
|
|
|
|
|
this.unsubscribers.push(() => eventBus.off(event, refresh));
|
|
|
|
|
});
|
2025-10-08 21:43:02 +02:00
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private onPointerDown = (e: PointerEvent): void => {
|
2025-10-08 21:43:02 +02:00
|
|
|
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
|
|
|
|
|
if (!handle) return;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
const element = handle.parentElement as SwpEventEl;
|
|
|
|
|
this.startResizing(element, e);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private startResizing(element: SwpEventEl, event: PointerEvent): void {
|
|
|
|
|
this.targetEl = element;
|
2025-10-08 21:43:02 +02:00
|
|
|
this.isResizing = true;
|
2025-11-06 22:14:35 +01:00
|
|
|
this.startY = event.clientY;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
const startHeight = element.offsetHeight;
|
2025-10-08 21:43:02 +02:00
|
|
|
this.startDurationMin = Math.max(
|
|
|
|
|
this.minDurationMin,
|
2025-11-05 21:53:08 +01:00
|
|
|
Math.round(this.positionUtils.pixelsToMinutes(startHeight))
|
2025-10-08 21:43:02 +02:00
|
|
|
);
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
this.setZIndexForResizing(element);
|
|
|
|
|
this.capturePointer(event);
|
2025-10-08 21:43:02 +02:00
|
|
|
document.documentElement.classList.add('swp--resizing');
|
2025-11-06 22:14:35 +01:00
|
|
|
event.preventDefault();
|
|
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
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);
|
2025-10-08 00:58:38 +02:00
|
|
|
}
|
2025-11-06 22:14:35 +01:00
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private onPointerMove = (e: PointerEvent): void => {
|
|
|
|
|
if (!this.isResizing || !this.targetEl) return;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
this.updateResizeHeight(e.clientY);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private updateResizeHeight(currentY: number): void {
|
|
|
|
|
const deltaY = currentY - this.startY;
|
|
|
|
|
this.direction = deltaY >= 0 ? 'grow' : 'shrink';
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-05 21:53:08 +01:00
|
|
|
const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin);
|
2025-11-06 22:14:35 +01:00
|
|
|
const rawHeight = startHeight + deltaY;
|
2025-11-05 21:53:08 +01:00
|
|
|
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
this.targetHeight = Math.max(minHeight, rawHeight);
|
2025-10-08 21:43:02 +02:00
|
|
|
|
|
|
|
|
if (this.animationId == null) {
|
2025-11-06 22:14:35 +01:00
|
|
|
this.currentHeight = this.targetEl?.offsetHeight!!;
|
2025-10-08 00:58:38 +02:00
|
|
|
this.animate();
|
|
|
|
|
}
|
2025-11-06 22:14:35 +01:00
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private animate = (): void => {
|
|
|
|
|
if (!this.isResizing || !this.targetEl) {
|
|
|
|
|
this.animationId = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
|
|
|
|
const diff = this.targetHeight - this.currentHeight;
|
2025-11-06 22:14:35 +01:00
|
|
|
|
|
|
|
|
if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) {
|
|
|
|
|
this.currentHeight += diff * this.ANIMATION_SPEED;
|
2025-10-08 21:43:02 +02:00
|
|
|
this.targetEl.updateHeight?.(this.currentHeight);
|
|
|
|
|
this.animationId = requestAnimationFrame(this.animate);
|
2025-10-08 00:58:38 +02:00
|
|
|
} else {
|
2025-11-06 22:14:35 +01:00
|
|
|
this.finalizeAnimation();
|
2025-10-08 00:58:38 +02:00
|
|
|
}
|
2025-10-08 21:43:02 +02:00
|
|
|
};
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private finalizeAnimation(): void {
|
|
|
|
|
if (!this.targetEl) return;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
this.currentHeight = this.targetHeight;
|
|
|
|
|
this.targetEl.updateHeight?.(this.currentHeight);
|
2025-10-08 21:43:02 +02:00
|
|
|
this.animationId = null;
|
2025-11-06 22:14:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
const currentHeight = this.targetEl.offsetHeight;
|
2025-11-05 21:53:08 +01:00
|
|
|
const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin);
|
2025-10-08 00:58:38 +02:00
|
|
|
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
|
2025-11-05 21:53:08 +01:00
|
|
|
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
|
2025-11-06 22:14:35 +01:00
|
|
|
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
this.targetEl.updateHeight?.(finalHeight);
|
2025-11-06 22:14:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private emitResizeEndEvent(): void {
|
|
|
|
|
if (!this.targetEl) return;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 22:18:06 +02:00
|
|
|
const eventId = this.targetEl.dataset.eventId || '';
|
2025-11-03 21:30:50 +01:00
|
|
|
const resizeEndPayload: IResizeEndEventPayload = {
|
2025-10-08 22:18:06 +02:00
|
|
|
eventId,
|
|
|
|
|
element: this.targetEl,
|
2025-11-06 22:14:35 +01:00
|
|
|
finalHeight: this.targetEl.offsetHeight
|
2025-10-08 22:18:06 +02:00
|
|
|
};
|
2025-11-06 22:14:35 +01:00
|
|
|
|
2025-10-08 22:18:06 +02:00
|
|
|
eventBus.emit('resize:end', resizeEndPayload);
|
2025-11-06 22:14:35 +01:00
|
|
|
}
|
2025-10-08 22:18:06 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private cleanupResizing(event: PointerEvent): void {
|
|
|
|
|
this.restoreZIndex();
|
|
|
|
|
this.releasePointer(event);
|
|
|
|
|
|
2025-10-08 00:58:38 +02:00
|
|
|
this.isResizing = false;
|
2025-10-08 21:43:02 +02:00
|
|
|
this.targetEl = null;
|
2025-11-06 22:14:35 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-06 22:14:35 +01:00
|
|
|
private releasePointer(event: PointerEvent): void {
|
|
|
|
|
if (!this.pointerCaptured) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
(event.target as Element).releasePointerCapture?.(event.pointerId);
|
2025-10-08 21:43:02 +02:00
|
|
|
this.pointerCaptured = false;
|
2025-11-06 22:14:35 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Pointer release failed:', error);
|
2025-10-08 21:43:02 +02:00
|
|
|
}
|
2025-11-06 22:14:35 +01:00
|
|
|
}
|
|
|
|
|
}
|