Improves event resizing with smooth animation

Replaces the previous rough event resizing implementation with a smooth, animated approach.

Uses pointer events for accurate tracking and adds a visual resize handle
for better user interaction.
Also refactors drag and drop to exclude resize handle.
This commit is contained in:
Janus C. H. Knudsen 2025-10-08 21:43:02 +02:00
parent ce0a9b19eb
commit 1e5b3166b2
4 changed files with 420 additions and 230 deletions

View file

@ -2,234 +2,248 @@ import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { calendarConfig } from '../core/CalendarConfig';
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
export class ResizeHandleManager {
private resizeZoneHeight = 15; // Must match CSS ::after height
private cachedEvents: HTMLElement[] = [];
// Resize state
private cachedEvents: SwpEventEl[] = [];
private isResizing = false;
private resizingElement: HTMLElement | null = null;
private initialHeight = 0;
private initialMouseY = 0;
private targetHeight = 0;
private currentHeight = 0;
private animationFrameId: number | null = null;
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';
// Snap configuration
private snapIntervalMinutes = 15;
private hourHeightPx: number;
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;
constructor() {
const gridSettings = calendarConfig.getGridSettings();
this.hourHeightPx = gridSettings.hourHeight;
this.snapIntervalMinutes = gridSettings.snapInterval;
const grid = calendarConfig.getGridSettings();
this.hourHeightPx = grid.hourHeight;
this.snapMin = grid.snapInterval;
this.minDurationMin = grid.minEventDuration ?? this.snapMin;
}
public initialize(): void {
this.refreshEventCache();
this.setupEventListeners();
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());
}
private minutesPerPx(): number {
return 60 / this.hourHeightPx;
}
private pxFromMinutes(min: number): number {
return (min / 60) * this.hourHeightPx;
}
private roundSnap(min: number, dir: 'grow' | 'shrink'): number {
const q = min / this.snapMin;
return (dir === 'grow' ? Math.ceil(q) : Math.floor(q)) * this.snapMin;
}
private refreshEventCache(): void {
this.cachedEvents = Array.from(
document.querySelectorAll<HTMLElement>('swp-day-columns swp-event')
document.querySelectorAll<SwpEventEl>('swp-day-columns swp-event')
);
}
private setupEventListeners(): void {
// Hover detection (only when not resizing and mouse button is up)
document.addEventListener('mousemove', (e: MouseEvent) => {
if (!this.isResizing) {
// Only check for resize zones when mouse button is up
if (e.buttons === 0) {
this.handleGlobalMouseMove(e);
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);
}
});
}
private attachGlobalListeners(): void {
// Use same pattern as DragDropManager - mouseenter to activate tracking
const calendarContainer = document.querySelector('swp-calendar-container');
if (calendarContainer) {
calendarContainer.addEventListener('mouseenter', (e) => {
const target = e.target as HTMLElement;
const eventElement = target.closest<SwpEventEl>('swp-event');
if (eventElement && !this.isResizing) {
this.isResizeZoneTrackingActive = true;
this.currentTrackedEvent = eventElement;
}
} else {
this.handleMouseMove(e);
}
});
// Resize mouse handling
document.addEventListener('mousedown', (e: MouseEvent) => {
this.handleMouseDown(e);
});
document.addEventListener('mouseup', (e: MouseEvent) => {
this.handleMouseUp(e);
});
// Cache refresh
eventBus.on(CoreEvents.GRID_RENDERED, () => this.refreshEventCache());
eventBus.on(CoreEvents.EVENTS_RENDERED, () => this.refreshEventCache());
eventBus.on(CoreEvents.EVENT_CREATED, () => this.refreshEventCache());
eventBus.on(CoreEvents.EVENT_UPDATED, () => this.refreshEventCache());
eventBus.on(CoreEvents.EVENT_DELETED, () => this.refreshEventCache());
}
private handleGlobalMouseMove(e: MouseEvent): void {
// Check all cached events to see if mouse is in their resize zone
const events = this.cachedEvents;
events.forEach(eventElement => {
// Skip the element we're currently resizing
if (this.resizingElement === eventElement) {
return;
}
const rect = eventElement.getBoundingClientRect();
const mouseY = e.clientY;
const mouseX = e.clientX;
// Check if mouse is within element bounds horizontally
const isInHorizontalBounds = mouseX >= rect.left && mouseX <= rect.right;
// Check if mouse is in bottom resize zone of the element
const distanceFromBottom = rect.bottom - mouseY;
const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= this.resizeZoneHeight;
if (isInHorizontalBounds && isInResizeZone) {
this.showResizeIndicator(eventElement);
console.log(`✅ In resize zone - bottom ${this.resizeZoneHeight}px`);
} else {
this.hideResizeIndicator(eventElement);
}
});
}
private showResizeIndicator(eventElement: HTMLElement): void {
// Check if indicator already exists
let indicator = eventElement.querySelector<HTMLElement>('swp-resize-indicator');
if (!indicator) {
indicator = document.createElement('swp-resize-indicator');
eventElement.appendChild(indicator);
}, true); // Capture phase
}
eventElement.setAttribute('data-resize-hover', 'true');
document.addEventListener('pointerdown', this.onPointerDown, true);
document.addEventListener('pointermove', this.onPointerMove, true);
document.addEventListener('pointerup', this.onPointerUp, true);
}
private hideResizeIndicator(eventElement: HTMLElement): void {
const indicator = eventElement.querySelector<HTMLElement>('swp-resize-indicator');
if (indicator) {
indicator.remove();
}
eventElement.removeAttribute('data-resize-hover');
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));
}
private handleMouseDown(e: MouseEvent): void {
const target = e.target as HTMLElement;
const eventElement = target.closest<HTMLElement>('swp-event[data-resize-hover="true"]');
private checkResizeZone(e: PointerEvent): void {
if (!this.isResizeZoneTrackingActive || !this.currentTrackedEvent || this.isResizing) return;
if (!eventElement) return;
const rect = this.currentTrackedEvent.getBoundingClientRect();
const mouseX = e.clientX;
const mouseY = e.clientY;
// Check if click is in bottom resize zone
const rect = eventElement.getBoundingClientRect();
const distanceFromBottom = rect.bottom - e.clientY;
// Check if mouse is still within event bounds
const isInBounds = mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom;
if (distanceFromBottom >= 0 && distanceFromBottom <= this.resizeZoneHeight) {
// START RESIZE
e.stopPropagation(); // Prevent DragDropManager from handling
e.preventDefault();
this.isResizing = true;
this.resizingElement = eventElement;
this.initialHeight = eventElement.offsetHeight;
this.initialMouseY = e.clientY;
// Set high z-index on event-group if exists, otherwise on event itself
const eventGroup = eventElement.closest<HTMLElement>('swp-event-group');
if (eventGroup) {
eventGroup.style.zIndex = '1000';
} else {
eventElement.style.zIndex = '1000';
}
console.log('🔄 Resize started', this.initialHeight);
}
}
private handleMouseMove(e: MouseEvent): void {
if (!this.isResizing || !this.resizingElement) return;
const deltaY = e.clientY - this.initialMouseY;
const rawHeight = this.initialHeight + deltaY;
// Apply minimum height
this.targetHeight = Math.max(30, rawHeight);
// Start animation loop if not already running
if (this.animationFrameId === null) {
this.currentHeight = this.resizingElement.offsetHeight;
this.animate();
}
}
private animate(): void {
if (!this.isResizing || !this.resizingElement) {
this.animationFrameId = null;
if (!isInBounds) {
// Mouse left event - deactivate tracking
this.hideResizeIndicator(this.currentTrackedEvent);
this.isResizeZoneTrackingActive = false;
this.currentTrackedEvent = null;
return;
}
// Smooth interpolation towards target
// Check if in resize zone (bottom 15px)
const distanceFromBottom = rect.bottom - mouseY;
const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= 15;
if (isInResizeZone) {
this.showResizeIndicator(this.currentTrackedEvent);
} else {
this.hideResizeIndicator(this.currentTrackedEvent);
}
}
private showResizeIndicator(el: SwpEventEl): void {
el.setAttribute('data-resize-hover', 'true');
}
private hideResizeIndicator(el: SwpEventEl): void {
el.removeAttribute('data-resize-hover');
}
private onPointerDown = (e: PointerEvent) => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
if (!handle) return;
const el = handle.parentElement as SwpEventEl;
this.targetEl = el;
this.isResizing = true;
this.startY = e.clientY;
// udled start-varighed fra højde
const startHeight = el.offsetHeight;
this.startDurationMin = Math.max(
this.minDurationMin,
Math.round(startHeight * this.minutesPerPx())
);
this.prevZ = (el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex;
(el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex = '1000';
(e.target as Element).setPointerCapture?.(e.pointerId);
this.pointerCaptured = true;
document.documentElement.classList.add('swp--resizing');
e.preventDefault();
};
private onPointerMove = (e: PointerEvent) => {
// Check resize zone if not resizing
if (!this.isResizing) {
this.checkResizeZone(e);
return;
}
// Continue with resize logic
if (!this.targetEl) return;
const dy = e.clientY - this.startY;
this.direction = dy >= 0 ? 'grow' : 'shrink';
// Calculate raw height from pixel delta (no snapping - 100% smooth like drag & drop)
const startHeight = this.pxFromMinutes(this.startDurationMin);
const rawHeight = startHeight + dy;
const minHeight = this.pxFromMinutes(this.minDurationMin);
this.targetHeight = Math.max(minHeight, rawHeight); // Raw height, no snap
if (this.animationId == null) {
this.currentHeight = this.targetEl.offsetHeight;
this.animate();
}
};
private animate = () => {
if (!this.isResizing || !this.targetEl) { this.animationId = null; return; }
const diff = this.targetHeight - this.currentHeight;
const step = diff * 0.3; // 30% of distance per frame
// Update if difference is significant
if (Math.abs(diff) > 0.5) {
this.currentHeight += step;
const swpEvent = this.resizingElement as any;
if (swpEvent.updateHeight) {
swpEvent.updateHeight(this.currentHeight);
}
this.animationFrameId = requestAnimationFrame(() => this.animate());
this.currentHeight += diff * 0.35;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = requestAnimationFrame(this.animate);
} else {
// Close enough - snap to target
this.currentHeight = this.targetHeight;
const swpEvent = this.resizingElement as any;
if (swpEvent.updateHeight) {
swpEvent.updateHeight(this.currentHeight);
}
this.animationFrameId = null;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = null;
}
}
};
private handleMouseUp(e: MouseEvent): void {
if (!this.isResizing || !this.resizingElement) return;
private onPointerUp = (e: PointerEvent) => {
if (!this.isResizing || !this.targetEl) return;
// Cancel animation
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.animationId != null) cancelAnimationFrame(this.animationId);
this.animationId = null;
// Snap to grid on mouse up
const snapDistancePx = (this.snapIntervalMinutes / 60) * this.hourHeightPx;
const currentHeight = this.resizingElement.offsetHeight;
// Snap to grid on pointer up (like DragDropManager does on mouseUp)
const currentHeight = this.targetEl.offsetHeight;
const snapDistancePx = this.pxFromMinutes(this.snapMin);
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
const finalHeight = Math.max(30, snappedHeight) - 3; //a little gap, so it doesn't cover the horizontal time lines
const minHeight = this.pxFromMinutes(this.minDurationMin);
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // lille gap til grid-linjer
const swpEvent = this.resizingElement as any;
if (swpEvent.updateHeight) {
swpEvent.updateHeight(finalHeight);
}
this.targetEl.updateHeight?.(finalHeight);
console.log('✅ Resize ended', finalHeight);
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
group.style.zIndex = this.prevZ ?? '';
this.prevZ = undefined;
// Clear z-index on event-group if exists, otherwise on event itself
const eventGroup = this.resizingElement.closest<HTMLElement>('swp-event-group');
if (eventGroup) {
eventGroup.style.zIndex = '';
} else {
this.resizingElement.style.zIndex = '';
}
// Cleanup state
this.isResizing = false;
this.resizingElement = null;
this.targetEl = null;
// Refresh cache for future operations
if (this.pointerCaptured) {
try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {}
this.pointerCaptured = false;
}
document.documentElement.classList.remove('swp--resizing');
this.refreshEventCache();
}
};
}