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
This commit is contained in:
Janus C. H. Knudsen 2025-11-06 22:14:35 +01:00
parent 04b6847f55
commit ccfc1a99b2
2 changed files with 165 additions and 154 deletions

View file

@ -11,10 +11,6 @@ export class ResizeHandleManager {
private isResizing = false; private isResizing = false;
private targetEl: SwpEventEl | 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 startY = 0;
private startDurationMin = 0; private startDurationMin = 0;
private direction: 'grow' | 'shrink' = 'grow'; private direction: 'grow' | 'shrink' = 'grow';
@ -25,33 +21,41 @@ export class ResizeHandleManager {
private currentHeight = 0; private currentHeight = 0;
private targetHeight = 0; private targetHeight = 0;
// cleanup
private unsubscribers: Array<() => void> = []; private unsubscribers: Array<() => void> = [];
private pointerCaptured = false; private pointerCaptured = false;
private prevZ?: string; private prevZ?: string;
private config: Configuration;
private positionUtils: PositionUtils;
constructor(config: Configuration, positionUtils: PositionUtils) { // Constants for better maintainability
this.config = config; private readonly ANIMATION_SPEED = 0.35;
this.positionUtils = positionUtils; 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; const grid = this.config.gridSettings;
this.snapMin = grid.snapInterval; this.snapMin = grid.snapInterval;
this.minDurationMin = this.snapMin; // Use snap interval as minimum duration this.minDurationMin = this.snapMin;
} }
public initialize(): void { public initialize(): void {
this.refreshEventCache(); this.refreshEventCache();
this.attachHandles(); this.attachHandles();
this.attachGlobalListeners(); this.attachGlobalListeners();
this.subToBus(); this.subscribeToEventBus();
} }
public destroy(): void { public destroy(): void {
this.removeEventListeners();
this.unsubscribers.forEach(unsubscribe => unsubscribe());
this.unsubscribers = [];
}
private removeEventListeners(): void {
document.removeEventListener('pointerdown', this.onPointerDown, true); document.removeEventListener('pointerdown', this.onPointerDown, true);
document.removeEventListener('pointermove', this.onPointerMove, true); document.removeEventListener('pointermove', this.onPointerMove, true);
document.removeEventListener('pointerup', this.onPointerUp, true); document.removeEventListener('pointerup', this.onPointerUp, true);
this.unsubscribers.forEach(u => u());
} }
private refreshEventCache(): void { private refreshEventCache(): void {
@ -61,189 +65,202 @@ export class ResizeHandleManager {
} }
private attachHandles(): void { private attachHandles(): void {
// ensure a single handle per event this.cachedEvents.forEach(element => {
this.cachedEvents.forEach(el => { if (!element.querySelector(':scope > swp-resize-handle')) {
if (!el.querySelector(':scope > swp-resize-handle')) { const handle = this.createResizeHandle();
const handle = document.createElement('swp-resize-handle'); element.appendChild(handle);
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
el.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 { 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;
}
}, true); // Capture phase
}
document.addEventListener('pointerdown', this.onPointerDown, true); document.addEventListener('pointerdown', this.onPointerDown, true);
document.addEventListener('pointermove', this.onPointerMove, true); document.addEventListener('pointermove', this.onPointerMove, true);
document.addEventListener('pointerup', this.onPointerUp, true); document.addEventListener('pointerup', this.onPointerUp, true);
} }
private subToBus(): void { private subscribeToEventBus(): void {
const sub = (ev: string, fn: () => void) => { const eventsToRefresh = [
eventBus.on(ev, fn); CoreEvents.GRID_RENDERED,
this.unsubscribers.push(() => eventBus.off(ev, fn)); CoreEvents.EVENTS_RENDERED,
CoreEvents.EVENT_CREATED,
CoreEvents.EVENT_UPDATED,
CoreEvents.EVENT_DELETED
];
const refresh = () => {
this.refreshEventCache();
this.attachHandles();
}; };
const refresh = () => { this.refreshEventCache(); this.attachHandles(); };
[CoreEvents.GRID_RENDERED, CoreEvents.EVENTS_RENDERED, eventsToRefresh.forEach(event => {
CoreEvents.EVENT_CREATED, CoreEvents.EVENT_UPDATED, eventBus.on(event, refresh);
CoreEvents.EVENT_DELETED].forEach(ev => sub(ev, refresh)); this.unsubscribers.push(() => eventBus.off(event, refresh));
});
} }
private checkResizeZone(e: PointerEvent): void { private onPointerDown = (e: PointerEvent): void => {
if (!this.isResizeZoneTrackingActive || !this.currentTrackedEvent || this.isResizing) return;
const rect = this.currentTrackedEvent.getBoundingClientRect();
const mouseX = e.clientX;
const mouseY = 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 (!isInBounds) {
// Mouse left event - deactivate tracking
this.hideResizeIndicator(this.currentTrackedEvent);
this.isResizeZoneTrackingActive = false;
this.currentTrackedEvent = null;
return;
}
// 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'); const handle = (e.target as HTMLElement).closest('swp-resize-handle');
if (!handle) return; if (!handle) return;
const el = handle.parentElement as SwpEventEl; const element = handle.parentElement as SwpEventEl;
this.targetEl = el; this.startResizing(element, e);
this.isResizing = true; };
this.startY = e.clientY;
// udled start-varighed fra højde private startResizing(element: SwpEventEl, event: PointerEvent): void {
const startHeight = el.offsetHeight; this.targetEl = element;
this.isResizing = true;
this.startY = event.clientY;
const startHeight = element.offsetHeight;
this.startDurationMin = Math.max( this.startDurationMin = Math.max(
this.minDurationMin, this.minDurationMin,
Math.round(this.positionUtils.pixelsToMinutes(startHeight)) Math.round(this.positionUtils.pixelsToMinutes(startHeight))
); );
this.prevZ = (el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex; this.setZIndexForResizing(element);
(el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex = '1000'; this.capturePointer(event);
(e.target as Element).setPointerCapture?.(e.pointerId);
this.pointerCaptured = true;
document.documentElement.classList.add('swp--resizing'); document.documentElement.classList.add('swp--resizing');
e.preventDefault(); 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 onPointerMove = (e: PointerEvent) => { private updateResizeHeight(currentY: number): void {
// Check resize zone if not resizing const deltaY = currentY - this.startY;
if (!this.isResizing) { this.direction = deltaY >= 0 ? 'grow' : 'shrink';
this.checkResizeZone(e);
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; 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.positionUtils.minutesToPixels(this.startDurationMin);
const rawHeight = startHeight + dy;
const minHeight = this.positionUtils.minutesToPixels(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 diff = this.targetHeight - this.currentHeight;
if (Math.abs(diff) > 0.5) {
this.currentHeight += diff * 0.35; if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) {
this.currentHeight += diff * this.ANIMATION_SPEED;
this.targetEl.updateHeight?.(this.currentHeight); this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = requestAnimationFrame(this.animate); this.animationId = requestAnimationFrame(this.animate);
} else { } else {
this.finalizeAnimation();
}
};
private finalizeAnimation(): void {
if (!this.targetEl) return;
this.currentHeight = this.targetHeight; this.currentHeight = this.targetHeight;
this.targetEl.updateHeight?.(this.currentHeight); this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = null; this.animationId = null;
} }
};
private onPointerUp = (e: PointerEvent) => { private onPointerUp = (e: PointerEvent): void => {
if (!this.isResizing || !this.targetEl) return; if (!this.isResizing || !this.targetEl) return;
if (this.animationId != null) cancelAnimationFrame(this.animationId); this.cleanupAnimation();
this.animationId = null; 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;
// Snap to grid on pointer up (like DragDropManager does on mouseUp)
const currentHeight = this.targetEl.offsetHeight; const currentHeight = this.targetEl.offsetHeight;
const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin); const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin);
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx; const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin); const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // lille gap til grid-linjer const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines
this.targetEl.updateHeight?.(finalHeight); this.targetEl.updateHeight?.(finalHeight);
}
private emitResizeEndEvent(): void {
if (!this.targetEl) return;
// Emit resize:end event for re-stacking
const eventId = this.targetEl.dataset.eventId || ''; const eventId = this.targetEl.dataset.eventId || '';
const resizeEndPayload: IResizeEndEventPayload = { const resizeEndPayload: IResizeEndEventPayload = {
eventId, eventId,
element: this.targetEl, element: this.targetEl,
finalHeight finalHeight: this.targetEl.offsetHeight
}; };
eventBus.emit('resize:end', resizeEndPayload);
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl; eventBus.emit('resize:end', resizeEndPayload);
group.style.zIndex = this.prevZ ?? ''; }
this.prevZ = undefined;
private cleanupResizing(event: PointerEvent): void {
this.restoreZIndex();
this.releasePointer(event);
this.isResizing = false; this.isResizing = false;
this.targetEl = null; this.targetEl = null;
if (this.pointerCaptured) {
try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {}
this.pointerCaptured = false;
}
document.documentElement.classList.remove('swp--resizing'); document.documentElement.classList.remove('swp--resizing');
this.refreshEventCache(); //TODO: We should avoid this caching. 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);
}
}
} }

View file

@ -109,8 +109,7 @@ swp-resize-handle {
} }
/* Show handle on hover */ /* Show handle on hover */
swp-day-columns swp-event:hover swp-resize-handle, swp-day-columns swp-event:hover swp-resize-handle {
swp-day-columns swp-event[data-resize-hover="true"] swp-resize-handle {
opacity: 1; opacity: 1;
} }
@ -127,11 +126,6 @@ swp-resize-handle::before {
0 0 4px rgba(0, 0, 0, 0.2); 0 0 4px rgba(0, 0, 0, 0.2);
} }
swp-day-columns swp-event[data-resize-hover="true"] {
cursor: ns-resize;
overflow: visible;
}
/* Global resizing state */ /* Global resizing state */
.swp--resizing { .swp--resizing {
user-select: none !important; user-select: none !important;