Adds core calendar package components including: - Base services for events, resources, and settings - Calendar app and orchestrator - Build and bundling configuration - IndexedDB storage setup Prepares foundational architecture for calendar functionality
290 lines
9 KiB
TypeScript
290 lines
9 KiB
TypeScript
import { IEventBus } from '../types/CalendarTypes';
|
|
import { IGridConfig } from '../core/IGridConfig';
|
|
import { pixelsToMinutes, minutesToPixels, snapToGrid } from '../utils/PositionUtils';
|
|
import { DateService } from '../core/DateService';
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
|
import { IResizeStartPayload, IResizeEndPayload } from '../types/ResizeTypes';
|
|
import { SwpEvent } from '../types/SwpEvent';
|
|
|
|
/**
|
|
* ResizeManager - Handles resize of calendar events
|
|
*
|
|
* Step 1: Handle creation on mouseover (CSS handles visibility)
|
|
* Step 2: Pointer events + resize start
|
|
* Step 3: RAF animation for smooth height update
|
|
* Step 4: Grid snapping + timestamp update
|
|
*/
|
|
|
|
interface ResizeState {
|
|
eventId: string;
|
|
element: HTMLElement;
|
|
handleElement: HTMLElement;
|
|
startY: number;
|
|
startHeight: number;
|
|
startDurationMinutes: number;
|
|
pointerId: number;
|
|
prevZIndex: string;
|
|
// Animation state
|
|
currentHeight: number;
|
|
targetHeight: number;
|
|
animationId: number | null;
|
|
}
|
|
|
|
export class ResizeManager {
|
|
private container: HTMLElement | null = null;
|
|
private resizeState: ResizeState | null = null;
|
|
|
|
private readonly Z_INDEX_RESIZING = '1000';
|
|
private readonly ANIMATION_SPEED = 0.35;
|
|
private readonly MIN_HEIGHT_MINUTES = 15;
|
|
|
|
constructor(
|
|
private eventBus: IEventBus,
|
|
private gridConfig: IGridConfig,
|
|
private dateService: DateService
|
|
) {}
|
|
|
|
/**
|
|
* Initialize resize functionality on container
|
|
*/
|
|
init(container: HTMLElement): void {
|
|
this.container = container;
|
|
|
|
// Mouseover listener for handle creation (capture phase like V1)
|
|
container.addEventListener('mouseover', this.handleMouseOver, true);
|
|
|
|
// Pointer listeners for resize (capture phase like V1)
|
|
document.addEventListener('pointerdown', this.handlePointerDown, true);
|
|
document.addEventListener('pointermove', this.handlePointerMove, true);
|
|
document.addEventListener('pointerup', this.handlePointerUp, true);
|
|
}
|
|
|
|
/**
|
|
* Create resize handle element
|
|
*/
|
|
private createResizeHandle(): HTMLElement {
|
|
const handle = document.createElement('swp-resize-handle');
|
|
handle.setAttribute('aria-label', 'Resize event');
|
|
handle.setAttribute('role', 'separator');
|
|
return handle;
|
|
}
|
|
|
|
/**
|
|
* Handle mouseover - create resize handle if not exists
|
|
*/
|
|
private handleMouseOver = (e: Event): void => {
|
|
const target = e.target as HTMLElement;
|
|
const eventElement = target.closest('swp-event') as HTMLElement;
|
|
|
|
if (!eventElement || this.resizeState) return;
|
|
|
|
// Check if handle already exists
|
|
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
|
|
const handle = this.createResizeHandle();
|
|
eventElement.appendChild(handle);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle pointerdown - start resize if on handle
|
|
*/
|
|
private handlePointerDown = (e: PointerEvent): void => {
|
|
const handle = (e.target as HTMLElement).closest('swp-resize-handle') as HTMLElement;
|
|
if (!handle) return;
|
|
|
|
const element = handle.parentElement as HTMLElement;
|
|
if (!element) return;
|
|
|
|
const eventId = element.dataset.eventId || '';
|
|
const startHeight = element.offsetHeight;
|
|
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
|
|
|
|
// Store previous z-index
|
|
const container = element.closest('swp-event-group') as HTMLElement ?? element;
|
|
const prevZIndex = container.style.zIndex;
|
|
|
|
// Set resize state
|
|
this.resizeState = {
|
|
eventId,
|
|
element,
|
|
handleElement: handle,
|
|
startY: e.clientY,
|
|
startHeight,
|
|
startDurationMinutes,
|
|
pointerId: e.pointerId,
|
|
prevZIndex,
|
|
// Animation state
|
|
currentHeight: startHeight,
|
|
targetHeight: startHeight,
|
|
animationId: null
|
|
};
|
|
|
|
// Elevate z-index
|
|
container.style.zIndex = this.Z_INDEX_RESIZING;
|
|
|
|
// Capture pointer for smooth tracking
|
|
try {
|
|
handle.setPointerCapture(e.pointerId);
|
|
} catch (err) {
|
|
console.warn('Pointer capture failed:', err);
|
|
}
|
|
|
|
// Add global resizing class
|
|
document.documentElement.classList.add('swp--resizing');
|
|
|
|
// Emit resize start event
|
|
this.eventBus.emit(CoreEvents.EVENT_RESIZE_START, {
|
|
eventId,
|
|
element,
|
|
startHeight
|
|
} as IResizeStartPayload);
|
|
|
|
e.preventDefault();
|
|
};
|
|
|
|
/**
|
|
* Handle pointermove - update target height during resize
|
|
*/
|
|
private handlePointerMove = (e: PointerEvent): void => {
|
|
if (!this.resizeState) return;
|
|
|
|
const deltaY = e.clientY - this.resizeState.startY;
|
|
const minHeight = (this.MIN_HEIGHT_MINUTES / 60) * this.gridConfig.hourHeight;
|
|
const newHeight = Math.max(minHeight, this.resizeState.startHeight + deltaY);
|
|
|
|
// Set target height for animation
|
|
this.resizeState.targetHeight = newHeight;
|
|
|
|
// Start animation if not running
|
|
if (this.resizeState.animationId === null) {
|
|
this.animateHeight();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* RAF animation loop for smooth height interpolation
|
|
*/
|
|
private animateHeight = (): void => {
|
|
if (!this.resizeState) return;
|
|
|
|
const diff = this.resizeState.targetHeight - this.resizeState.currentHeight;
|
|
|
|
// Stop animation when close enough
|
|
if (Math.abs(diff) < 0.5) {
|
|
this.resizeState.animationId = null;
|
|
return;
|
|
}
|
|
|
|
// Interpolate towards target (35% per frame like V1)
|
|
this.resizeState.currentHeight += diff * this.ANIMATION_SPEED;
|
|
this.resizeState.element.style.height = `${this.resizeState.currentHeight}px`;
|
|
|
|
// Update timestamp display (snapped)
|
|
this.updateTimestampDisplay();
|
|
|
|
// Continue animation
|
|
this.resizeState.animationId = requestAnimationFrame(this.animateHeight);
|
|
};
|
|
|
|
/**
|
|
* Update timestamp display with snapped end time
|
|
*/
|
|
private updateTimestampDisplay(): void {
|
|
if (!this.resizeState) return;
|
|
|
|
const timeEl = this.resizeState.element.querySelector('swp-event-time');
|
|
if (!timeEl) return;
|
|
|
|
// Get start time from element position
|
|
const top = parseFloat(this.resizeState.element.style.top) || 0;
|
|
const startMinutesFromGrid = pixelsToMinutes(top, this.gridConfig);
|
|
const startMinutes = (this.gridConfig.dayStartHour * 60) + startMinutesFromGrid;
|
|
|
|
// Calculate snapped end time from current height
|
|
const snappedHeight = snapToGrid(this.resizeState.currentHeight, this.gridConfig);
|
|
const durationMinutes = pixelsToMinutes(snappedHeight, this.gridConfig);
|
|
const endMinutes = startMinutes + durationMinutes;
|
|
|
|
// Format and update
|
|
const start = this.minutesToDate(startMinutes);
|
|
const end = this.minutesToDate(endMinutes);
|
|
timeEl.textContent = this.dateService.formatTimeRange(start, end);
|
|
}
|
|
|
|
/**
|
|
* Convert minutes since midnight to Date
|
|
*/
|
|
private minutesToDate(minutes: number): Date {
|
|
const date = new Date();
|
|
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
|
|
return date;
|
|
};
|
|
|
|
/**
|
|
* Handle pointerup - finish resize
|
|
*/
|
|
private handlePointerUp = (e: PointerEvent): void => {
|
|
if (!this.resizeState) return;
|
|
|
|
// Cancel any pending animation
|
|
if (this.resizeState.animationId !== null) {
|
|
cancelAnimationFrame(this.resizeState.animationId);
|
|
}
|
|
|
|
// Release pointer capture
|
|
try {
|
|
this.resizeState.handleElement.releasePointerCapture(e.pointerId);
|
|
} catch (err) {
|
|
console.warn('Pointer release failed:', err);
|
|
}
|
|
|
|
// Snap final height to grid
|
|
this.snapToGridFinal();
|
|
|
|
// Update timestamp one final time
|
|
this.updateTimestampDisplay();
|
|
|
|
// Restore z-index
|
|
const container = this.resizeState.element.closest('swp-event-group') as HTMLElement ?? this.resizeState.element;
|
|
container.style.zIndex = this.resizeState.prevZIndex;
|
|
|
|
// Remove global resizing class
|
|
document.documentElement.classList.remove('swp--resizing');
|
|
|
|
// Get columnKey and date from parent column
|
|
const column = this.resizeState.element.closest('swp-day-column') as HTMLElement;
|
|
const columnKey = column?.dataset.columnKey || '';
|
|
const date = column?.dataset.date || '';
|
|
|
|
// Create SwpEvent from element (reads top/height/eventId from element)
|
|
const swpEvent = SwpEvent.fromElement(
|
|
this.resizeState.element,
|
|
columnKey,
|
|
date,
|
|
this.gridConfig
|
|
);
|
|
|
|
// Emit resize end event
|
|
this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, {
|
|
swpEvent
|
|
} as IResizeEndPayload);
|
|
|
|
// Reset state
|
|
this.resizeState = null;
|
|
};
|
|
|
|
/**
|
|
* Snap final height to grid interval
|
|
*/
|
|
private snapToGridFinal(): void {
|
|
if (!this.resizeState) return;
|
|
|
|
const currentHeight = this.resizeState.element.offsetHeight;
|
|
const snappedHeight = snapToGrid(currentHeight, this.gridConfig);
|
|
const minHeight = minutesToPixels(this.MIN_HEIGHT_MINUTES, this.gridConfig);
|
|
const finalHeight = Math.max(minHeight, snappedHeight);
|
|
|
|
this.resizeState.element.style.height = `${finalHeight}px`;
|
|
this.resizeState.currentHeight = finalHeight;
|
|
}
|
|
}
|