Moving away from Azure Devops #1
6 changed files with 314 additions and 1 deletions
|
|
@ -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<HeaderDrawerManager>();
|
||||
builder.registerType(DragDropManager).as<DragDropManager>();
|
||||
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
|
||||
builder.registerType(ResizeManager).as<ResizeManager>();
|
||||
|
||||
// Demo app
|
||||
builder.registerType(DemoApp).as<DemoApp>();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
282
src/v2/managers/ResizeManager.ts
Normal file
282
src/v2/managers/ResizeManager.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
16
src/v2/types/ResizeTypes.ts
Normal file
16
src/v2/types/ResizeTypes.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue