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
|
// Managers
|
||||||
import { DragDropManager } from './managers/DragDropManager';
|
import { DragDropManager } from './managers/DragDropManager';
|
||||||
import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
||||||
|
import { ResizeManager } from './managers/ResizeManager';
|
||||||
|
|
||||||
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
|
@ -151,6 +152,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(HeaderDrawerManager).as<HeaderDrawerManager>();
|
builder.registerType(HeaderDrawerManager).as<HeaderDrawerManager>();
|
||||||
builder.registerType(DragDropManager).as<DragDropManager>();
|
builder.registerType(DragDropManager).as<DragDropManager>();
|
||||||
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
|
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
|
||||||
|
builder.registerType(ResizeManager).as<ResizeManager>();
|
||||||
|
|
||||||
// Demo app
|
// Demo app
|
||||||
builder.registerType(DemoApp).as<DemoApp>();
|
builder.registerType(DemoApp).as<DemoApp>();
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ export const CoreEvents = {
|
||||||
EVENT_DRAG_CANCEL: 'event:drag-cancel',
|
EVENT_DRAG_CANCEL: 'event:drag-cancel',
|
||||||
EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change',
|
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
|
||||||
EDGE_SCROLL_TICK: 'edge-scroll:tick',
|
EDGE_SCROLL_TICK: 'edge-scroll:tick',
|
||||||
EDGE_SCROLL_STARTED: 'edge-scroll:started',
|
EDGE_SCROLL_STARTED: 'edge-scroll:started',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { DataSeeder } from '../workers/DataSeeder';
|
||||||
import { ViewConfig } from '../core/ViewConfig';
|
import { ViewConfig } from '../core/ViewConfig';
|
||||||
import { DragDropManager } from '../managers/DragDropManager';
|
import { DragDropManager } from '../managers/DragDropManager';
|
||||||
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
|
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
|
||||||
|
import { ResizeManager } from '../managers/ResizeManager';
|
||||||
|
|
||||||
export class DemoApp {
|
export class DemoApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
|
|
@ -25,7 +26,8 @@ export class DemoApp {
|
||||||
private indexedDBContext: IndexedDBContext,
|
private indexedDBContext: IndexedDBContext,
|
||||||
private dataSeeder: DataSeeder,
|
private dataSeeder: DataSeeder,
|
||||||
private dragDropManager: DragDropManager,
|
private dragDropManager: DragDropManager,
|
||||||
private edgeScrollManager: EdgeScrollManager
|
private edgeScrollManager: EdgeScrollManager,
|
||||||
|
private resizeManager: ResizeManager
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|
@ -61,6 +63,9 @@ export class DemoApp {
|
||||||
const scrollableContent = this.container.querySelector('swp-scrollable-content') as HTMLElement;
|
const scrollableContent = this.container.querySelector('swp-scrollable-content') as HTMLElement;
|
||||||
this.edgeScrollManager.init(scrollableContent);
|
this.edgeScrollManager.init(scrollableContent);
|
||||||
|
|
||||||
|
// Init resize
|
||||||
|
this.resizeManager.init(this.container);
|
||||||
|
|
||||||
// Setup event handlers
|
// Setup event handlers
|
||||||
this.setupNavigation();
|
this.setupNavigation();
|
||||||
this.setupDrawerToggle();
|
this.setupDrawerToggle();
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ export class DragDropManager {
|
||||||
|
|
||||||
private handlePointerDown = (e: PointerEvent): void => {
|
private handlePointerDown = (e: PointerEvent): void => {
|
||||||
const target = e.target as HTMLElement;
|
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;
|
const eventElement = target.closest('swp-event') as HTMLElement;
|
||||||
|
|
||||||
if (!eventElement) return;
|
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