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
|
|
@ -11,10 +11,6 @@ export class ResizeHandleManager {
|
|||
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';
|
||||
|
|
@ -25,33 +21,41 @@ export class ResizeHandleManager {
|
|||
private currentHeight = 0;
|
||||
private targetHeight = 0;
|
||||
|
||||
// cleanup
|
||||
private unsubscribers: Array<() => void> = [];
|
||||
private pointerCaptured = false;
|
||||
private prevZ?: string;
|
||||
private config: Configuration;
|
||||
private positionUtils: PositionUtils;
|
||||
|
||||
constructor(config: Configuration, positionUtils: PositionUtils) {
|
||||
this.config = config;
|
||||
this.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(
|
||||
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.finalizeAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
private finalizeAnimation(): void {
|
||||
if (!this.targetEl) return;
|
||||
|
||||
this.currentHeight = this.targetHeight;
|
||||
this.targetEl.updateHeight?.(this.currentHeight);
|
||||
this.animationId = null;
|
||||
}
|
||||
};
|
||||
|
||||
private onPointerUp = (e: PointerEvent) => {
|
||||
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;
|
||||
eventBus.emit('resize:end', resizeEndPayload);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue