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:
parent
04b6847f55
commit
ccfc1a99b2
2 changed files with 165 additions and 154 deletions
|
|
@ -10,48 +10,52 @@ export class ResizeHandleManager {
|
||||||
private cachedEvents: SwpEventEl[] = [];
|
private cachedEvents: SwpEventEl[] = [];
|
||||||
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';
|
||||||
|
|
||||||
private snapMin: number;
|
private snapMin: number;
|
||||||
private minDurationMin: number;
|
private minDurationMin: number;
|
||||||
private animationId: number | null = null;
|
private animationId: number | null = null;
|
||||||
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;
|
// 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) {
|
constructor(
|
||||||
this.config = config;
|
private config: Configuration,
|
||||||
this.positionUtils = positionUtils;
|
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.currentHeight = this.targetHeight;
|
this.finalizeAnimation();
|
||||||
this.targetEl.updateHeight?.(this.currentHeight);
|
|
||||||
this.animationId = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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.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);
|
eventBus.emit('resize:end', resizeEndPayload);
|
||||||
|
}
|
||||||
|
|
||||||
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
|
private cleanupResizing(event: PointerEvent): void {
|
||||||
group.style.zIndex = this.prevZ ?? '';
|
this.restoreZIndex();
|
||||||
this.prevZ = undefined;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue