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;
|
|
|
|
|
|
|
|
|
|
// Resize zone tracking (like DragDropManager hover tracking)
|
|
|
|
|
private isResizeZoneTrackingActive = false;
|
|
|
|
|
private currentTrackedEvent: SwpEventEl | null = null;
|
|
|
|
|
|
|
|
|
|
private startY = 0;
|
|
|
|
|
private startDurationMin = 0;
|
|
|
|
|
private direction: 'grow' | 'shrink' = 'grow';
|
2025-10-08 00:58:38 +02: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;
|
|
|
|
|
|
|
|
|
|
// cleanup
|
|
|
|
|
private unsubscribers: Array<() => void> = [];
|
|
|
|
|
private pointerCaptured = false;
|
|
|
|
|
private prevZ?: string;
|
2025-11-03 21:30:50 +01:00
|
|
|
private config: Configuration;
|
2025-11-05 21:53:08 +01:00
|
|
|
private positionUtils: PositionUtils;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-11-05 21:53:08 +01:00
|
|
|
constructor(config: Configuration, positionUtils: PositionUtils) {
|
2025-10-30 23:47:30 +01:00
|
|
|
this.config = config;
|
2025-11-05 21:53:08 +01:00
|
|
|
this.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-10-08 22:18:06 +02:00
|
|
|
this.minDurationMin = this.snapMin; // Use snap interval as minimum duration
|
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();
|
|
|
|
|
this.subToBus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public destroy(): void {
|
|
|
|
|
document.removeEventListener('pointerdown', this.onPointerDown, true);
|
|
|
|
|
document.removeEventListener('pointermove', this.onPointerMove, true);
|
|
|
|
|
document.removeEventListener('pointerup', this.onPointerUp, true);
|
|
|
|
|
this.unsubscribers.forEach(u => u());
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
|
|
|
|
// ensure a single handle per event
|
|
|
|
|
this.cachedEvents.forEach(el => {
|
|
|
|
|
if (!el.querySelector(':scope > swp-resize-handle')) {
|
|
|
|
|
const handle = document.createElement('swp-resize-handle');
|
|
|
|
|
handle.setAttribute('aria-label', 'Resize event');
|
|
|
|
|
handle.setAttribute('role', 'separator');
|
|
|
|
|
el.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-10-08 21:43:02 +02:00
|
|
|
private attachGlobalListeners(): void {
|
|
|
|
|
// Use same pattern as DragDropManager - mouseenter to activate tracking
|
|
|
|
|
const calendarContainer = document.querySelector('swp-calendar-container');
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
if (calendarContainer) {
|
|
|
|
|
calendarContainer.addEventListener('mouseenter', (e) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
const eventElement = target.closest<SwpEventEl>('swp-event');
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
if (eventElement && !this.isResizing) {
|
|
|
|
|
this.isResizeZoneTrackingActive = true;
|
|
|
|
|
this.currentTrackedEvent = eventElement;
|
|
|
|
|
}
|
|
|
|
|
}, true); // Capture phase
|
|
|
|
|
}
|
2025-10-06 23:38:01 +02:00
|
|
|
|
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-10-08 21:43:02 +02:00
|
|
|
private subToBus(): void {
|
|
|
|
|
const sub = (ev: string, fn: () => void) => {
|
|
|
|
|
eventBus.on(ev, fn);
|
|
|
|
|
this.unsubscribers.push(() => eventBus.off(ev, fn));
|
|
|
|
|
};
|
|
|
|
|
const refresh = () => { this.refreshEventCache(); this.attachHandles(); };
|
|
|
|
|
[CoreEvents.GRID_RENDERED, CoreEvents.EVENTS_RENDERED,
|
|
|
|
|
CoreEvents.EVENT_CREATED, CoreEvents.EVENT_UPDATED,
|
|
|
|
|
CoreEvents.EVENT_DELETED].forEach(ev => sub(ev, refresh));
|
|
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private checkResizeZone(e: PointerEvent): void {
|
|
|
|
|
if (!this.isResizeZoneTrackingActive || !this.currentTrackedEvent || this.isResizing) return;
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
const rect = this.currentTrackedEvent.getBoundingClientRect();
|
|
|
|
|
const mouseX = e.clientX;
|
|
|
|
|
const mouseY = e.clientY;
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
// Check if mouse is still within event bounds
|
|
|
|
|
const isInBounds = mouseX >= rect.left && mouseX <= rect.right &&
|
|
|
|
|
mouseY >= rect.top && mouseY <= rect.bottom;
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
if (!isInBounds) {
|
|
|
|
|
// Mouse left event - deactivate tracking
|
|
|
|
|
this.hideResizeIndicator(this.currentTrackedEvent);
|
|
|
|
|
this.isResizeZoneTrackingActive = false;
|
|
|
|
|
this.currentTrackedEvent = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
// Check if in resize zone (bottom 15px)
|
|
|
|
|
const distanceFromBottom = rect.bottom - mouseY;
|
|
|
|
|
const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= 15;
|
2025-10-06 23:38:01 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
if (isInResizeZone) {
|
|
|
|
|
this.showResizeIndicator(this.currentTrackedEvent);
|
|
|
|
|
} else {
|
|
|
|
|
this.hideResizeIndicator(this.currentTrackedEvent);
|
2025-10-06 23:38:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private showResizeIndicator(el: SwpEventEl): void {
|
|
|
|
|
el.setAttribute('data-resize-hover', 'true');
|
2025-10-06 23:38:01 +02:00
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private hideResizeIndicator(el: SwpEventEl): void {
|
|
|
|
|
el.removeAttribute('data-resize-hover');
|
|
|
|
|
}
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private onPointerDown = (e: PointerEvent) => {
|
|
|
|
|
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
|
|
|
|
|
if (!handle) return;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
const el = handle.parentElement as SwpEventEl;
|
|
|
|
|
this.targetEl = el;
|
|
|
|
|
this.isResizing = true;
|
|
|
|
|
this.startY = e.clientY;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
// udled start-varighed fra højde
|
|
|
|
|
const startHeight = el.offsetHeight;
|
|
|
|
|
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-10-08 21:43:02 +02:00
|
|
|
this.prevZ = (el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex;
|
|
|
|
|
(el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex = '1000';
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
(e.target as Element).setPointerCapture?.(e.pointerId);
|
|
|
|
|
this.pointerCaptured = true;
|
|
|
|
|
document.documentElement.classList.add('swp--resizing');
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
};
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private onPointerMove = (e: PointerEvent) => {
|
|
|
|
|
// Check resize zone if not resizing
|
|
|
|
|
if (!this.isResizing) {
|
|
|
|
|
this.checkResizeZone(e);
|
|
|
|
|
return;
|
2025-10-08 00:58:38 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
// Continue with resize logic
|
|
|
|
|
if (!this.targetEl) return;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
const dy = e.clientY - this.startY;
|
|
|
|
|
this.direction = dy >= 0 ? 'grow' : 'shrink';
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
// Calculate raw height from pixel delta (no snapping - 100% smooth like drag & drop)
|
2025-11-05 21:53:08 +01:00
|
|
|
const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin);
|
2025-10-08 21:43:02 +02:00
|
|
|
const rawHeight = startHeight + dy;
|
2025-11-05 21:53:08 +01:00
|
|
|
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
this.targetHeight = Math.max(minHeight, rawHeight); // Raw height, no snap
|
|
|
|
|
|
|
|
|
|
if (this.animationId == null) {
|
|
|
|
|
this.currentHeight = this.targetEl.offsetHeight;
|
2025-10-08 00:58:38 +02:00
|
|
|
this.animate();
|
|
|
|
|
}
|
2025-10-08 21:43:02 +02:00
|
|
|
};
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
private animate = () => {
|
|
|
|
|
if (!this.isResizing || !this.targetEl) { this.animationId = null; return; }
|
2025-10-08 00:58:38 +02:00
|
|
|
|
|
|
|
|
const diff = this.targetHeight - this.currentHeight;
|
|
|
|
|
if (Math.abs(diff) > 0.5) {
|
2025-10-08 21:43:02 +02:00
|
|
|
this.currentHeight += diff * 0.35;
|
|
|
|
|
this.targetEl.updateHeight?.(this.currentHeight);
|
|
|
|
|
this.animationId = requestAnimationFrame(this.animate);
|
2025-10-08 00:58:38 +02:00
|
|
|
} else {
|
|
|
|
|
this.currentHeight = this.targetHeight;
|
2025-10-08 21:43:02 +02:00
|
|
|
this.targetEl.updateHeight?.(this.currentHeight);
|
|
|
|
|
this.animationId = null;
|
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-10-08 21:43:02 +02:00
|
|
|
private onPointerUp = (e: PointerEvent) => {
|
|
|
|
|
if (!this.isResizing || !this.targetEl) return;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
if (this.animationId != null) cancelAnimationFrame(this.animationId);
|
|
|
|
|
this.animationId = null;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
// Snap to grid on pointer up (like DragDropManager does on mouseUp)
|
|
|
|
|
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-10-08 21:43:02 +02:00
|
|
|
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // lille gap til grid-linjer
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
this.targetEl.updateHeight?.(finalHeight);
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 22:18:06 +02:00
|
|
|
// Emit resize:end event for re-stacking
|
|
|
|
|
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,
|
|
|
|
|
finalHeight
|
|
|
|
|
};
|
|
|
|
|
eventBus.emit('resize:end', resizeEndPayload);
|
|
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
|
|
|
|
|
group.style.zIndex = this.prevZ ?? '';
|
|
|
|
|
this.prevZ = undefined;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
|
|
|
|
this.isResizing = false;
|
2025-10-08 21:43:02 +02:00
|
|
|
this.targetEl = null;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 21:43:02 +02:00
|
|
|
if (this.pointerCaptured) {
|
|
|
|
|
try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {}
|
|
|
|
|
this.pointerCaptured = false;
|
|
|
|
|
}
|
|
|
|
|
document.documentElement.classList.remove('swp--resizing');
|
2025-10-08 00:58:38 +02:00
|
|
|
this.refreshEventCache();
|
2025-10-08 21:43:02 +02:00
|
|
|
};
|
2025-10-06 23:38:01 +02:00
|
|
|
}
|