Replaces global singleton configuration with dependency injection Introduces more modular and testable approach to configuration Removes direct references to calendarConfig in multiple components Adds explicit configuration passing to constructors Improves code maintainability and reduces global state dependencies
261 lines
8.6 KiB
TypeScript
261 lines
8.6 KiB
TypeScript
import { eventBus } from '../core/EventBus';
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
|
import { CalendarConfig } from '../core/CalendarConfig';
|
|
import { ResizeEndEventPayload } from '../types/EventTypes';
|
|
|
|
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
|
|
|
|
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 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;
|
|
private config: CalendarConfig;
|
|
|
|
constructor(config: CalendarConfig) {
|
|
this.config = config;
|
|
const grid = this.config.getGridSettings();
|
|
this.hourHeightPx = grid.hourHeight;
|
|
this.snapMin = grid.snapInterval;
|
|
this.minDurationMin = this.snapMin; // Use snap interval as minimum duration
|
|
}
|
|
|
|
public initialize(): void {
|
|
this.refreshEventCache();
|
|
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<SwpEventEl>('swp-day-columns swp-event')
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}, 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));
|
|
};
|
|
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 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) => {
|
|
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;
|
|
if (Math.abs(diff) > 0.5) {
|
|
this.currentHeight += diff * 0.35;
|
|
this.targetEl.updateHeight?.(this.currentHeight);
|
|
this.animationId = requestAnimationFrame(this.animate);
|
|
} else {
|
|
this.currentHeight = this.targetHeight;
|
|
this.targetEl.updateHeight?.(this.currentHeight);
|
|
this.animationId = null;
|
|
}
|
|
};
|
|
|
|
private onPointerUp = (e: PointerEvent) => {
|
|
if (!this.isResizing || !this.targetEl) return;
|
|
|
|
if (this.animationId != null) cancelAnimationFrame(this.animationId);
|
|
this.animationId = null;
|
|
|
|
// 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 minHeight = this.pxFromMinutes(this.minDurationMin);
|
|
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // lille gap til grid-linjer
|
|
|
|
this.targetEl.updateHeight?.(finalHeight);
|
|
|
|
// Emit resize:end event for re-stacking
|
|
const eventId = this.targetEl.dataset.eventId || '';
|
|
const resizeEndPayload: ResizeEndEventPayload = {
|
|
eventId,
|
|
element: this.targetEl,
|
|
finalHeight
|
|
};
|
|
eventBus.emit('resize:end', resizeEndPayload);
|
|
|
|
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
|
|
group.style.zIndex = this.prevZ ?? '';
|
|
this.prevZ = undefined;
|
|
|
|
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();
|
|
};
|
|
}
|