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

@ -10,48 +10,52 @@ export class ResizeHandleManager {
private cachedEvents: SwpEventEl[] = [];
private isResizing = false;
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';
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;
private config: Configuration;
private positionUtils: PositionUtils;
// Constants for better maintainability
private readonly ANIMATION_SPEED = 0.35;
private readonly Z_INDEX_RESIZING = '1000';
private readonly EVENT_REFRESH_THRESHOLD = 0.5;
constructor(config: Configuration, positionUtils: PositionUtils) {
this.config = config;
this.positionUtils = positionUtils;
constructor(
private config: Configuration,
private positionUtils: PositionUtils
) {
const grid = this.config.gridSettings;
this.snapMin = grid.snapInterval;
this.minDurationMin = this.snapMin; // Use snap interval as minimum duration
this.minDurationMin = this.snapMin;
}
public initialize(): void {
this.refreshEventCache();
this.attachHandles();
this.attachGlobalListeners();
this.subToBus();
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);
this.unsubscribers.forEach(u => u());
}
private refreshEventCache(): void {
@ -61,189 +65,202 @@ export class ResizeHandleManager {
}
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);
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 {
// 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('pointermove', this.onPointerMove, true);
document.addEventListener('pointerup', this.onPointerUp, true);
}
private subToBus(): void {
const sub = (ev: string, fn: () => void) => {
eventBus.on(ev, fn);
this.unsubscribers.push(() => eventBus.off(ev, fn));
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();
};
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));
eventsToRefresh.forEach(event => {
eventBus.on(event, refresh);
this.unsubscribers.push(() => eventBus.off(event, refresh));
});
}
private checkResizeZone(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) => {
private onPointerDown = (e: PointerEvent): void => {
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;
const element = handle.parentElement as SwpEventEl;
this.startResizing(element, e);
};
// udled start-varighed fra højde
const startHeight = el.offsetHeight;
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.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;
this.setZIndexForResizing(element);
this.capturePointer(event);
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) => {
// Check resize zone if not resizing
if (!this.isResizing) {
this.checkResizeZone(e);
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;
}
// 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;
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.animationId = requestAnimationFrame(this.animate);
} else {
this.currentHeight = this.targetHeight;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = null;
this.finalizeAnimation();
}
};
private onPointerUp = (e: PointerEvent) => {
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;
if (this.animationId != null) cancelAnimationFrame(this.animationId);
this.animationId = null;
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;
// Snap to grid on pointer up (like DragDropManager does on mouseUp)
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; // lille gap til grid-linjer
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines
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 resizeEndPayload: IResizeEndEventPayload = {
eventId,
element: this.targetEl,
finalHeight
finalHeight: this.targetEl.offsetHeight
};
eventBus.emit('resize:end', resizeEndPayload);
}
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
group.style.zIndex = this.prevZ ?? '';
this.prevZ = undefined;
private cleanupResizing(event: PointerEvent): void {
this.restoreZIndex();
this.releasePointer(event);
this.isResizing = false;
this.targetEl = null;
if (this.pointerCaptured) {
try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {}
this.pointerCaptured = false;
}
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 */
swp-day-columns swp-event:hover swp-resize-handle,
swp-day-columns swp-event[data-resize-hover="true"] swp-resize-handle {
swp-day-columns swp-event:hover swp-resize-handle {
opacity: 1;
}
@ -127,11 +126,6 @@ swp-resize-handle::before {
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 */
.swp--resizing {
user-select: none !important;