diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index 8367ec2..f996ed6 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -53,6 +53,7 @@ import { ResourceScheduleService } from './storage/schedules/ResourceScheduleSer // Managers import { DragDropManager } from './managers/DragDropManager'; import { EdgeScrollManager } from './managers/EdgeScrollManager'; +import { ResizeManager } from './managers/ResizeManager'; const defaultTimeFormatConfig: ITimeFormatConfig = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -151,6 +152,7 @@ export function createV2Container(): Container { builder.registerType(HeaderDrawerManager).as(); builder.registerType(DragDropManager).as(); builder.registerType(EdgeScrollManager).as(); + builder.registerType(ResizeManager).as(); // Demo app builder.registerType(DemoApp).as(); diff --git a/src/v2/constants/CoreEvents.ts b/src/v2/constants/CoreEvents.ts index f5b7e85..fbb93f0 100644 --- a/src/v2/constants/CoreEvents.ts +++ b/src/v2/constants/CoreEvents.ts @@ -37,6 +37,10 @@ export const CoreEvents = { EVENT_DRAG_CANCEL: 'event:drag-cancel', EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change', + // Event resize + EVENT_RESIZE_START: 'event:resize-start', + EVENT_RESIZE_END: 'event:resize-end', + // Edge scroll EDGE_SCROLL_TICK: 'edge-scroll:tick', EDGE_SCROLL_STARTED: 'edge-scroll:started', diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index 1d5046a..34e5f35 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -9,6 +9,7 @@ import { DataSeeder } from '../workers/DataSeeder'; import { ViewConfig } from '../core/ViewConfig'; import { DragDropManager } from '../managers/DragDropManager'; import { EdgeScrollManager } from '../managers/EdgeScrollManager'; +import { ResizeManager } from '../managers/ResizeManager'; export class DemoApp { private animator!: NavigationAnimator; @@ -25,7 +26,8 @@ export class DemoApp { private indexedDBContext: IndexedDBContext, private dataSeeder: DataSeeder, private dragDropManager: DragDropManager, - private edgeScrollManager: EdgeScrollManager + private edgeScrollManager: EdgeScrollManager, + private resizeManager: ResizeManager ) {} async init(): Promise { @@ -61,6 +63,9 @@ export class DemoApp { const scrollableContent = this.container.querySelector('swp-scrollable-content') as HTMLElement; this.edgeScrollManager.init(scrollableContent); + // Init resize + this.resizeManager.init(this.container); + // Setup event handlers this.setupNavigation(); this.setupDrawerToggle(); diff --git a/src/v2/managers/DragDropManager.ts b/src/v2/managers/DragDropManager.ts index 2ef32a3..639f0ad 100644 --- a/src/v2/managers/DragDropManager.ts +++ b/src/v2/managers/DragDropManager.ts @@ -75,6 +75,10 @@ export class DragDropManager { private handlePointerDown = (e: PointerEvent): void => { const target = e.target as HTMLElement; + + // Ignore if clicking on resize handle + if (target.closest('swp-resize-handle')) return; + const eventElement = target.closest('swp-event') as HTMLElement; if (!eventElement) return; diff --git a/src/v2/managers/ResizeManager.ts b/src/v2/managers/ResizeManager.ts new file mode 100644 index 0000000..d5892d4 --- /dev/null +++ b/src/v2/managers/ResizeManager.ts @@ -0,0 +1,282 @@ +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'; + +/** + * 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.id || ''; + 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'); + + // Emit resize end event + const newHeight = this.resizeState.currentHeight; + const newDurationMinutes = pixelsToMinutes(newHeight, this.gridConfig); + + this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, { + eventId: this.resizeState.eventId, + element: this.resizeState.element, + newHeight, + newDurationMinutes + } 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; + } +} diff --git a/src/v2/types/ResizeTypes.ts b/src/v2/types/ResizeTypes.ts new file mode 100644 index 0000000..db544b1 --- /dev/null +++ b/src/v2/types/ResizeTypes.ts @@ -0,0 +1,16 @@ +/** + * ResizeTypes - Event payloads for resize operations + */ + +export interface IResizeStartPayload { + eventId: string; + element: HTMLElement; + startHeight: number; +} + +export interface IResizeEndPayload { + eventId: string; + element: HTMLElement; + newHeight: number; + newDurationMinutes: number; +}